diff --git a/AGENTS.md b/AGENTS.md index 8f77b412fe..6f08c18163 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,15 @@ inside the background PowerShell process. See CONTRIBUTING.md for full setup details and dependency requirements. +### Agent changes are cross-surface changes + +Before changing an agent name, avatar, model, access rule, channel membership, +mention behavior, or historical message presentation, read +[`docs/agent-surface-map.md`](docs/agent-surface-map.md). It inventories the +relay events, precedence rules, write paths, caches, desktop/web routes, UI +consumers, and required tests. Update the map in the same change when a route, +source of truth, consumer, or invalidation boundary changes. + --- ## Quality Gates diff --git a/Cargo.toml b/Cargo.toml index 3268cfaf8d..8cb6998ff5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -136,6 +136,18 @@ buzz-sdk = { path = "crates/buzz-sdk" } buzz-ws-client = { path = "crates/buzz-ws-client" } buzz-relay-mesh = { path = "crates/buzz-relay-mesh" } +# Dev profile — workspace crates keep full debug info; dependencies carry none. +# Rust's default `debug = true` emits DWARF/PDB for all ~800 dependency crates, +# and that debug info is the majority of a debug `target/` by size. We almost +# never step into a dependency, so dropping it costs nothing we use while +# keeping our own crates fully debuggable. Recompiling deps is unaffected — +# this changes what is emitted, not what is built. +[profile.dev] +debug = true + +[profile.dev.package."*"] +debug = false + # CI profile — builds the relay for desktop e2e. Dependencies keep full # release optimization (warm from main's cache; they carry the runtime hot # path: tokio/sqlx/axum). Workspace crates build at opt-level 1 — enough for diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 700d5e8dcf..768e6e15da 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -20,6 +20,68 @@ use crate::usage::{TurnUsage, UsageTracker}; /// Lines exceeding this limit are rejected to prevent OOM from rogue agents. const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB +/// Build the process invocation for an ACP adapter. +/// +/// npm installs expose adapters as `.cmd` shims on Windows. `CreateProcess` +/// cannot execute those files directly (OS error 193), so route only those +/// shims through the system command processor. Native executables and every +/// non-Windows platform retain the direct-exec path. +fn windows_batch_spawn(command: &str, args: &[String]) -> (String, Vec) { + #[cfg(windows)] + { + let is_batch = std::path::Path::new(command) + .extension() + .map(|extension| { + matches!( + extension.to_string_lossy().to_ascii_lowercase().as_str(), + "cmd" | "bat" + ) + }) + .unwrap_or(false); + if is_batch { + // npm's PowerShell shim only forwards stdin when PowerShell itself + // detects pipeline input. A Rust pipe is not reported that way, so + // ACP initialize hangs. Execute the installed JavaScript entrypoint + // with Node directly, preserving the harness's stdio handles. + let shim_path = std::path::Path::new(command); + let package_name = shim_path.file_stem().and_then(|stem| stem.to_str()); + let npm_root = shim_path.parent(); + let script = npm_root.zip(package_name).map(|(root, package)| { + root.join("node_modules") + .join("@agentclientprotocol") + .join(package) + .join("dist") + .join("index.js") + }); + if let Some(script) = script.filter(|path| path.is_file()) { + let sibling_node = npm_root + .map(|root| root.join("node.exe")) + .filter(|path| path.is_file()); + let node = sibling_node + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "node.exe".to_string()); + let mut node_args = vec![script.display().to_string()]; + node_args.extend_from_slice(args); + return (node, node_args); + } + + // Non-npm batch adapters have no sibling PowerShell shim. Keep the + // compatibility fallback for simple batch files; catalogued Buzz + // runtimes all take the safer branch above. + return ( + std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()), + std::iter::once("/D".to_string()) + .chain(std::iter::once("/C".to_string())) + .chain(std::iter::once(command.to_string())) + .chain(args.iter().cloned()) + .collect(), + ); + } + } + + (command.to_string(), args.to_vec()) +} + /// An MCP server configuration passed to `session/new`. /// /// Corresponds to the `McpServerStdio` variant in the ACP schema. @@ -456,8 +518,9 @@ impl AcpClient { ) -> Result { use std::process::Stdio; - let mut cmd = tokio::process::Command::new(command); - cmd.args(args) + let (spawn_command, spawn_args) = windows_batch_spawn(command, args); + let mut cmd = tokio::process::Command::new(spawn_command); + cmd.args(spawn_args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) // Inherit stderr so agent logs are visible in the harness terminal. @@ -2256,6 +2319,39 @@ fn configure_no_window(cmd: &mut tokio::process::Command) { mod tests { use super::*; + #[test] + fn native_adapter_spawn_stays_direct() { + let args = vec!["acp".to_string()]; + let (command, actual_args) = windows_batch_spawn("adapter.exe", &args); + assert_eq!(command, "adapter.exe"); + assert_eq!(actual_args, args); + } + + #[cfg(windows)] + #[test] + fn windows_npm_adapter_uses_node_entrypoint() { + let temp = + std::env::temp_dir().join(format!("buzz-acp-shim-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&temp).expect("tempdir"); + let cmd_shim = temp.join("codex-acp.cmd"); + let script = temp + .join("node_modules") + .join("@agentclientprotocol") + .join("codex-acp") + .join("dist") + .join("index.js"); + std::fs::create_dir_all(script.parent().expect("script parent")).expect("package dirs"); + std::fs::write(&cmd_shim, "@echo off\r\n").expect("cmd shim"); + std::fs::write(&script, "").expect("Node entrypoint"); + let args = vec!["acp".to_string(), "--flag=value with spaces".to_string()]; + let (command, actual_args) = + windows_batch_spawn(cmd_shim.to_str().expect("utf8 path"), &args); + assert_eq!(command, "node.exe"); + assert_eq!(actual_args[0], script.display().to_string()); + assert_eq!(&actual_args[1..], args); + std::fs::remove_dir_all(temp).expect("remove tempdir"); + } + #[test] fn stop_reason_parses_all_known_values() { assert_eq!(StopReason::from_str("end_turn"), Some(StopReason::EndTurn)); diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 8e14fee195..ff87a33a1a 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -13,8 +13,8 @@ use crate::mcp::McpRegistry; use crate::mcp::ResultBudget; use crate::types::{ - AgentError, ContentBlock, HistoryItem, ProviderStop, StopReason, ToolCall, ToolResult, - ToolResultContent, TurnTotalState, + AgentError, ContentBlock, HistoryItem, ProviderStop, SessionUsageBaseline, StopReason, + ToolCall, ToolResult, ToolResultContent, TurnTotalState, }; use crate::wire::{self, WireSender}; @@ -150,9 +150,40 @@ pub struct RunCtx<'a> { /// Reset to `Unseen` at turn start in `run()`. Callers must not derive a /// total by summing input+output — that is the UI display approximation only. pub turn_total_state: &'a mut TurnTotalState, + /// Session-cumulative counters as they stood when this turn began. Added to + /// the `turn_*` accumulators above to report a cumulative figure mid-turn; + /// the session's own copy is only advanced once, after the turn returns. + pub usage_baseline: SessionUsageBaseline, } impl RunCtx<'_> { + /// Send a session-cumulative `usage_update` reflecting everything observed + /// up to and including the most recent LLM response. + /// + /// The figure is the turn-start baseline plus this turn's running + /// accumulators, which is exactly what `session/prompt` will fold into the + /// session once the turn returns — so a mid-turn notification and the + /// end-of-turn one agree, and a turn that never returns has still reported + /// everything but its final in-flight request. + async fn emit_usage_update(&self) { + let base = self.usage_baseline; + let payload = wire::usage_update_payload( + base.input_tokens + .saturating_add(self.turn_input_tokens.unwrap_or(0)), + base.output_tokens + .saturating_add(self.turn_output_tokens.unwrap_or(0)), + base.cached_input_tokens + .saturating_add(self.turn_cached_input_tokens.unwrap_or(0)), + base.total_state.merge_session(*self.turn_total_state), + self.effective_model, + ); + wire::send( + self.wire, + wire::goose_session_update(self.session_id, payload), + ) + .await; + } + pub async fn run(&mut self, prompt: Vec) -> Result { let user_text = prompt_to_text(prompt)?; if user_text.len() > MAX_PROMPT_BYTES { @@ -299,6 +330,23 @@ impl RunCtx<'_> { // this gate rather than representing absent categories as zero. if response.input_tokens.is_some() || response.output_tokens.is_some() { *self.turn_total_state = self.turn_total_state.fold(response.total_tokens); + // Report what the turn has burned SO FAR, before running the + // next round. A turn is many provider round-trips over many + // minutes, and until this point the only report was the one + // `session/prompt` sends after the turn returns — so a turn + // that was cancelled, timed out, or whose process was killed + // reported nothing at all, and its tokens (already billed) + // existed only in this stack frame. Reporting per round bounds + // the loss to the single request in flight. + // + // Emitting more than one `usage_update` per turn is expected by + // the consumer: buzz-acp's UsageTracker advances its committed + // baseline only when the turn's metric is published, so every + // notification within a turn measures from the same frozen + // baseline and the last one seen is the turn's true total. + // goose behaves the same way, which is why the tracker was + // written to tolerate it. + self.emit_usage_update().await; } if !response.reasoning.is_empty() { diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 9a45bf4c98..6cd7b6808f 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -658,6 +658,7 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender effective_model_override, run_id, mut steer_rx, + usage_baseline, ) = match acquire_session(&app, &p.session_id).await { Ok(v) => v, Err(reason) => { @@ -709,6 +710,7 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender turn_output_tokens: &mut turn_output_tokens, turn_cached_input_tokens: &mut turn_cached_input_tokens, turn_total_state: &mut turn_total_state, + usage_baseline, }; let result = ctx.run(p.prompt).await; if let Some(s) = app.sessions.lock().await.get_mut(&sid) { @@ -766,28 +768,16 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender if let Some((accumulated_in, accumulated_out, accumulated_cached, accumulated_total)) = accumulated { - // Build the usage_update payload. `accumulatedTotalTokens` is only - // included when the cumulative is exactly known — never when Unseen - // (no total ever observed) or Unknown (at least one turn lacked a - // total). A goose consumer that doesn't recognise the field ignores it. - let mut update = serde_json::json!({ - "sessionUpdate": "usage_update", - // used: total tokens as a context-usage proxy; - // contextLimit: 0 (buzz-agent has no context limit tracking). - "used": accumulated_in.saturating_add(accumulated_out), - "contextLimit": 0u64, - "accumulatedInputTokens": accumulated_in, - "accumulatedOutputTokens": accumulated_out, - // A subset of accumulatedInputTokens, not an addition to - // it. Extends goose's usage_update shape; a consumer that - // does not know the field ignores it and prices exactly as - // it did before. - "accumulatedCachedInputTokens": accumulated_cached, - "model": effective_model_str, - }); - if let crate::types::TurnTotalState::Exact(total) = accumulated_total { - update["accumulatedTotalTokens"] = serde_json::json!(total); - } + // Same builder the run loop uses for its per-round reports, so the + // final notification is shape-identical to the ones that preceded + // it and a consumer taking the high-water mark lands on this one. + let update = wire::usage_update_payload( + accumulated_in, + accumulated_out, + accumulated_cached, + accumulated_total, + effective_model_str, + ); wire::send(&wire_tx, goose_session_update(&sid, update)).await; } } @@ -821,6 +811,7 @@ async fn acquire_session( Option, String, mpsc::UnboundedReceiver>, + crate::types::SessionUsageBaseline, ), &'static str, > { @@ -857,6 +848,17 @@ async fn acquire_session( effective_model, run_id, steer_rx, + // Snapshot rather than a handle: the run loop reports cumulative usage + // after every LLM round, and taking the sessions lock on each of those + // would serialise concurrent sessions behind one another's provider + // round-trips. Nothing else advances these counters while this turn + // holds `busy`, so the snapshot cannot go stale under it. + crate::types::SessionUsageBaseline { + input_tokens: s.accumulated_input_tokens, + output_tokens: s.accumulated_output_tokens, + cached_input_tokens: s.accumulated_cached_input_tokens, + total_state: s.accumulated_total_state, + }, )) } diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index 343a75bf72..e386421981 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -308,6 +308,30 @@ impl TurnTotalState { } } +/// The session-cumulative usage counters as of the START of a turn. +/// +/// Copied out of the session under the lock when a turn begins and handed to +/// `RunCtx` by value, so the run loop can emit a cumulative `usage_update` +/// after every LLM round without reaching back into `App.sessions` (which it +/// holds no handle to, and which is locked by the turn's own bookkeeping at +/// both ends). +/// +/// This exists so that usage is durable *during* a turn rather than only after +/// it. The counters a turn accrues live in the prompt task's stack frame until +/// the turn returns; a process killed mid-turn takes them with it and the +/// tokens are billed by the provider but recorded nowhere. That is not +/// hypothetical — it silently under-reported a long-horizon benchmark's cost by +/// several-fold, because every phase of a `continue_until_timeout` run is +/// terminated mid-turn by design. +#[derive(Debug, Clone, Copy, Default)] +pub struct SessionUsageBaseline { + pub input_tokens: u64, + pub output_tokens: u64, + /// The cache-served subset of `input_tokens`, not an addition to it. + pub cached_input_tokens: u64, + pub total_state: TurnTotalState, +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum StopReason { EndTurn, diff --git a/crates/buzz-agent/src/wire.rs b/crates/buzz-agent/src/wire.rs index 7b50e7982a..634fca03af 100644 --- a/crates/buzz-agent/src/wire.rs +++ b/crates/buzz-agent/src/wire.rs @@ -148,6 +148,48 @@ pub fn goose_session_update(sid: &str, update: Value) -> Value { }) } +/// Build the `usage_update` payload for a `_goose/unstable/session/update`. +/// +/// Shared by the two places that report usage — after each LLM round inside a +/// turn, and once more when the turn completes — so the wire shape cannot drift +/// between them. A consumer takes the high-water mark per session, so the +/// mid-turn payloads are supersets of each other and the final one wins; a +/// divergence in field names or units between the two call sites would instead +/// show up as tokens silently vanishing, which is the failure this reporting +/// exists to prevent. +/// +/// All counts are SESSION-cumulative, matching goose, so buzz-acp's +/// `UsageTracker` can compute per-turn deltas symmetrically for both agents. +pub fn usage_update_payload( + accumulated_input_tokens: u64, + accumulated_output_tokens: u64, + accumulated_cached_input_tokens: u64, + accumulated_total: crate::types::TurnTotalState, + model: &str, +) -> Value { + let mut update = json!({ + "sessionUpdate": "usage_update", + // used: total tokens as a context-usage proxy; + // contextLimit: 0 (buzz-agent has no context limit tracking). + "used": accumulated_input_tokens.saturating_add(accumulated_output_tokens), + "contextLimit": 0u64, + "accumulatedInputTokens": accumulated_input_tokens, + "accumulatedOutputTokens": accumulated_output_tokens, + // A subset of accumulatedInputTokens, not an addition to it. Extends + // goose's usage_update shape; a consumer that does not know the field + // ignores it and prices exactly as it did before. + "accumulatedCachedInputTokens": accumulated_cached_input_tokens, + "model": model, + }); + // Only when the cumulative is exactly known — never when Unseen (no total + // ever observed) or Unknown (at least one turn lacked a total). A goose + // consumer that doesn't recognise the field ignores it. + if let Some(total) = accumulated_total.exact_value() { + update["accumulatedTotalTokens"] = json!(total); + } + update +} + /// A `session/update` notification carrying a `update._meta.goose.` field. /// Used to advertise `activeRunId` (so steer-capable clients can target the /// in-flight run) and `queuedSteer` (so they can correlate an accepted steer diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs index d51220f869..8b3eb14da7 100644 --- a/crates/buzz-agent/tests/fake_llm.rs +++ b/crates/buzz-agent/tests/fake_llm.rs @@ -947,6 +947,135 @@ async fn no_usage_turn_emits_no_usage_notification() { h.shutdown().await; } +/// Usage must be reported after EVERY provider round, not only once the turn +/// returns. +/// +/// A turn is many provider round-trips over many minutes. While the only report +/// was the one `session/prompt` sends after the turn returns, a turn whose +/// process was killed mid-flight reported nothing at all: its counters lived in +/// the prompt task's stack frame, the provider had already billed them, and no +/// consumer ever saw them. That is not a corner case for a long-horizon +/// benchmark — every phase of a `continue_until_timeout` run is terminated +/// mid-turn by design, which under-reported one measured run's cost several-fold. +/// +/// Two rounds with distinct usage. The assertion that matters is the FIRST +/// notification: it must carry round 1's counts alone, proving it was sent +/// before round 2 had returned, so a kill between the rounds would still have +/// left round 1 on the wire. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn usage_is_reported_after_each_round_not_only_at_turn_end() { + let url = spawn_fake_llm(vec![ + openai_tool_call_with_usage("call_round1", "fake__noop", json!({}), 15, 6), + openai_text_with_usage("done", 20, 8), + ]) + .await; + let mut h = Harness::spawn(&url).await; + let sid = init_session(&mut h).await; + + let p_id = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), + ) + .await; + + let (frames_before, response) = recv_until_with_drain(&mut h, |v| v["id"] == p_id).await; + assert_eq!( + response["result"]["stopReason"], "end_turn", + "turn must complete with end_turn" + ); + + let usage: Vec<&Value> = frames_before + .iter() + .filter(|v| is_usage_update(v)) + .collect(); + assert!( + usage.len() >= 2, + "expected a usage_update per round (2 rounds), got {}; frames: {frames_before:#?}", + usage.len() + ); + + // Round 1 alone — emitted while round 2 was still outstanding. + assert_eq!( + usage[0]["params"]["update"]["accumulatedInputTokens"], + json!(15u64), + "first notification must carry round 1's input tokens only" + ); + assert_eq!( + usage[0]["params"]["update"]["accumulatedOutputTokens"], + json!(6u64), + "first notification must carry round 1's output tokens only" + ); + + // The last one is the turn total and is what a high-water-mark consumer keeps. + let last = usage[usage.len() - 1]; + assert_eq!( + last["params"]["update"]["accumulatedInputTokens"], + json!(35u64), + "final notification must carry the turn total 15+20=35" + ); + assert_eq!( + last["params"]["update"]["accumulatedOutputTokens"], + json!(14u64), + "final notification must carry the turn total 6+8=14" + ); + + h.shutdown().await; +} + +/// A mid-turn report must be SESSION-cumulative, not turn-local. +/// +/// The baseline handed to the run loop is a snapshot taken when the turn began; +/// if it were dropped, a consumer taking the high-water mark per session would +/// see turn 2's first round (a small number) arrive after turn 1's total and +/// discard it, silently losing turn 2 for any turn that never completed. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mid_turn_usage_includes_earlier_turns() { + let url = spawn_fake_llm(vec![ + openai_text_with_usage("turn one", 10, 5), + openai_tool_call_with_usage("call_t2", "fake__noop", json!({}), 20, 8), + openai_text_with_usage("turn two done", 30, 9), + ]) + .await; + let mut h = Harness::spawn(&url).await; + let sid = init_session(&mut h).await; + + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 1"}]}), + ) + .await; + let (_, _) = recv_until_with_drain(&mut h, |v| v["id"] == p1).await; + + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 2"}]}), + ) + .await; + let (frames_before, _) = recv_until_with_drain(&mut h, |v| v["id"] == p2).await; + + let first = frames_before + .iter() + .find(|v| is_usage_update(v)) + .unwrap_or_else(|| { + panic!("expected a usage_update during turn 2; frames: {frames_before:#?}") + }); + assert_eq!( + first["params"]["update"]["accumulatedInputTokens"], + json!(30u64), + "turn 2 round 1 must report 10 (turn 1) + 20 (this round), not 20" + ); + assert_eq!( + first["params"]["update"]["accumulatedOutputTokens"], + json!(13u64), + "turn 2 round 1 must report 5 (turn 1) + 8 (this round), not 8" + ); + + h.shutdown().await; +} + /// When a turn is cancelled AFTER the provider has already returned a response /// (so token counts are observed), buzz-agent must still emit the usage /// notification before the cancelled `session/prompt` response. diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index 7d7211240c..85e2be6f28 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -20,6 +20,8 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli owner_pubkey, access_tier, channel_add_policy, + models_json, + model, } => { let display_name = display_name.trim(); if display_name.is_empty() { @@ -84,6 +86,19 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli if let Some(pubkey) = owner_pubkey { validate_hex64(pubkey)?; } + let models = models_json + .as_deref() + .map(serde_json::from_str::) + .transpose() + .map_err(|error| { + CliError::Usage(format!("--models-json must be valid JSON: {error}")) + })? + .unwrap_or_else(|| serde_json::json!([])); + if !models.is_array() { + return Err(CliError::Usage( + "--models-json must be a JSON array".to_string(), + )); + } let content = serde_json::json!({ "name": display_name, "display_name": display_name, @@ -96,6 +111,8 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli "owner_pubkey": owner_pubkey, "access_tier": access_tier, "channel_add_policy": channel_add_policy, + "models": models, + "model": model, }) .to_string(); let builder = nostr::EventBuilder::new( diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index b0985abf62..e197e6cdef 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -288,6 +288,12 @@ pub enum AgentsCmd { /// Who may add this agent to channels: anyone, owner_only, or nobody #[arg(long, default_value = "anyone")] channel_add_policy: String, + /// JSON array of runtime-advertised model objects (`id`, optional `name`) + #[arg(long)] + models_json: Option, + /// Model selected by this hosted runtime + #[arg(long)] + model: Option, }, /// Open a prefilled create-agent form in the owner's Buzz Desktop DraftCreate { diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index b1be7c5038..757fa84f17 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -304,6 +304,12 @@ pub const KIND_MANAGED_AGENT: u32 = 30177; /// Content carries only sanitized fields: no env vars, no `respond_to` /// allowlist pubkeys, no source or local ids, no filesystem paths, no secrets. pub const KIND_TEAM_CATALOG: u32 = 30178; +/// Buzz hosted-agent configuration (parameterized replaceable, admin-authored). +/// +/// Addressed by `(admin pubkey, kind, agent pubkey)`. Clients use this public, +/// secret-free projection to override a hosted agent's display name, avatar, +/// and desired model without requiring custody of the agent's signing key. +pub const KIND_HOSTED_AGENT_CONFIG: u32 = 30179; // NIP-56 reporting /// NIP-56: Report an event, pubkey, or blob to relay moderators (kind:1984). diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 6104171cca..3f70adc8a6 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -24,7 +24,9 @@ use axum::{ use serde::Deserialize; use serde_json::Value; -use crate::handlers::side_effects::{publish_nip43_member_added, publish_nip43_membership_list}; +use crate::handlers::side_effects::{ + emit_system_message, publish_nip43_member_added, publish_nip43_membership_list, +}; use buzz_core::invite::{ hash_v2_code, validate_v2_code, DEFAULT_INVITE_TTL_SECS, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, MIN_INVITE_TTL_SECS, V2_PREFIX, @@ -45,6 +47,47 @@ const CLAIM_RATE_LIMIT: u32 = 10; /// bound is required in addition to expiry. pub(crate) const CLAIM_RATE_CACHE_CAPACITY: u64 = 10_000; +/// Emit the durable, idempotent onboarding trigger after a community invite is +/// claimed for the first time. The trigger is anchored in `#general`, where the +/// welcome workflow posts, but its identity is the community membership claim +/// rather than a channel membership upsert. +async fn emit_first_join_trigger( + tenant: &buzz_core::tenant::TenantContext, + state: &Arc, + member_pubkey: &str, +) { + let channel = match state.db.list_channels(tenant.community(), None).await { + Ok(channels) => channels + .into_iter() + .find(|channel| channel.name == "general"), + Err(error) => { + tracing::warn!(%error, member = %member_pubkey, "could not resolve #general for first-join workflow"); + return; + } + }; + let Some(channel) = channel else { + tracing::warn!(member = %member_pubkey, "#general is missing; first-join workflow not emitted"); + return; + }; + + if let Err(error) = emit_system_message( + tenant, + state, + channel.id, + serde_json::json!({ + "type": "member_joined", + "actor": member_pubkey, + "target": member_pubkey, + "role": "member", + "scope": "community", + }), + ) + .await + { + tracing::warn!(%error, member = %member_pubkey, "first-join workflow trigger failed"); + } +} + /// Body for `POST /api/invites`. #[derive(Debug, Default, Deserialize)] pub struct MintInviteRequest { @@ -415,6 +458,7 @@ pub async fn claim_invite( if let Err(e) = publish_nip43_membership_list(&tenant, &state).await { tracing::warn!("failed to publish NIP-43 membership list after v2 claim: {e}"); } + emit_first_join_trigger(&tenant, &state, &claimer_hex).await; Ok(Json(serde_json::json!({ "status": "joined", "community_id": tenant.community().to_string(), @@ -490,6 +534,7 @@ pub async fn claim_invite( if let Err(e) = publish_nip43_membership_list(&tenant, &state).await { tracing::warn!("failed to publish NIP-43 membership list after claim: {e}"); } + emit_first_join_trigger(&tenant, &state, &claimer_hex).await; } Ok(Json(serde_json::json!({ diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index fcd0d70728..82b44ef367 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -19,21 +19,22 @@ use buzz_core::kind::{ KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, - KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, - KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, - KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, - KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, - KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, - KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, - KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, - KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, - KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, - KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, - RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_HOSTED_AGENT_CONFIG, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, + KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, + KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, + KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, + KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, + KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, + KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, + KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, + KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, + KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, + KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, + KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, + KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, + RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -428,6 +429,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_TEAM | KIND_MANAGED_AGENT | KIND_TEAM_CATALOG + | KIND_HOSTED_AGENT_CONFIG // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). | KIND_GIT_REPO_ANNOUNCEMENT diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 660a55fef3..7a6c28c19c 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -11,7 +11,7 @@ use buzz_core::kind::{ KIND_GIT_REPO_ANNOUNCEMENT, KIND_IA_ARCHIVED, KIND_IA_ARCHIVED_LIST, KIND_IA_UNARCHIVED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, - KIND_THREAD_SUMMARY, + KIND_SYSTEM_MESSAGE, KIND_THREAD_SUMMARY, }; use buzz_core::StoredEvent; use buzz_db::channel::{MemberRecord, MemberRole}; @@ -33,7 +33,7 @@ pub fn is_admin_kind(kind: u32) -> bool { /// handled in `ingest_event()` before storage so we can short-circuit on /// duplicates without storing the event at all. pub fn is_side_effect_kind(kind: u32) -> bool { - matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | KIND_AGENT_PROFILE | 41001..=41003 | 40099) + matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | KIND_AGENT_PROFILE | 41001..=41003 | KIND_SYSTEM_MESSAGE) } async fn evict_live_channel_subscriptions( @@ -765,18 +765,25 @@ pub async fn emit_system_message( ) -> anyhow::Result<()> { let channel_tag = Tag::parse(["h", &channel_id.to_string()])?; - let event = EventBuilder::new(Kind::Custom(40099), content.to_string()) - .tags([channel_tag]) - .sign_with_keys(&state.relay_keypair) - .map_err(|e| anyhow::anyhow!("failed to sign system message: {e}"))?; + let event = EventBuilder::new( + Kind::Custom(KIND_SYSTEM_MESSAGE as u16), + content.to_string(), + ) + .tags([channel_tag]) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| anyhow::anyhow!("failed to sign system message: {e}"))?; - if let Err(e) = state + let stored = match state .db .insert_event(tenant.community(), &event, Some(channel_id)) .await { - warn!(channel = %channel_id, error = %e, "system message insert failed"); - } + Ok((stored, _)) => Some(stored), + Err(e) => { + warn!(channel = %channel_id, error = %e, "system message insert failed"); + None + } + }; // Fan out to subscribers if let Err(e) = state @@ -787,6 +794,16 @@ pub async fn emit_system_message( warn!("System message fan-out failed: {e}"); } + if let Some(stored) = stored { + if let Err(e) = state + .workflow_engine + .on_event(tenant.community(), &stored) + .await + { + warn!(channel = %channel_id, error = %e, "system message workflow dispatch failed"); + } + } + Ok(()) } @@ -1294,17 +1311,19 @@ async fn handle_put_user( // No role tag = no role change: preserve an existing member's current role and // fall back to Member only for a new member. Unconditionally defaulting to // Member let a bare PUT_USER silently demote an existing owner/admin. + let existing_member = state + .db + .get_members(tenant.community(), channel_id) + .await? + .into_iter() + .find(|member| member.pubkey == target_pubkey); let role: MemberRole = match extract_tag_value(event, "role") { Some(role_str) => role_str .parse() .map_err(|_| anyhow::anyhow!("invalid role: {role_str}"))?, - None => state - .db - .get_members(tenant.community(), channel_id) - .await? - .iter() - .find(|m| m.pubkey == target_pubkey) - .and_then(|m| m.role.parse().ok()) + None => existing_member + .as_ref() + .and_then(|member| member.role.parse().ok()) .unwrap_or(MemberRole::Member), }; @@ -1324,17 +1343,23 @@ async fn handle_put_user( let actor_hex = hex::encode(&actor_bytes); let target_hex = hex::encode(&target_pubkey); - emit_system_message( - tenant, - state, - channel_id, - serde_json::json!({ - "type": "member_joined", - "actor": actor_hex, - "target": target_hex, - }), - ) - .await?; + // PUT_USER is also used for idempotent reconciliation and role updates. + // Only a genuinely new active membership is a join; otherwise a policy + // replay or role edit would retrigger onboarding workflows. + if existing_member.is_none() { + emit_system_message( + tenant, + state, + channel_id, + serde_json::json!({ + "type": "member_joined", + "actor": actor_hex, + "target": target_hex, + "role": role.as_str(), + }), + ) + .await?; + } if let Err(e) = emit_group_discovery_events(tenant, state, channel_id).await { warn!(channel = %channel_id, error = %e, "NIP-29 group discovery emission failed"); @@ -1981,6 +2006,7 @@ async fn handle_join_request( "type": "member_joined", "actor": actor_hex, "target": actor_hex, + "role": "member", }), ) .await?; diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index 7aaa3d1702..09c1819d53 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -44,7 +44,9 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::OnceLock; -use buzz_core::kind::{event_kind_u32, is_workflow_execution_kind, KIND_REACTION}; +use buzz_core::kind::{ + event_kind_u32, is_workflow_execution_kind, KIND_REACTION, KIND_SYSTEM_MESSAGE, +}; use buzz_core::tenant::CommunityId; use buzz_db::workflow::RunStatus; use buzz_db::Db; @@ -879,6 +881,17 @@ async fn should_fire_workflow( trigger_ctx: &executor::TriggerContext, workflow_id: uuid::Uuid, ) -> bool { + if let TriggerDef::MemberJoined { include_bots } = def.trigger { + if trigger_ctx.get_field("type") != Some("member_joined") + || trigger_ctx.get_field("scope") != Some("community") + { + return false; + } + if !include_bots && trigger_ctx.get_field("role") == Some("bot") { + return false; + } + } + if let TriggerDef::ReactionAdded { emoji: Some(ref expected), } = def.trigger @@ -1008,6 +1021,22 @@ pub fn build_trigger_context(event: &buzz_core::StoredEvent) -> executor::Trigge event.event.id.to_hex() }; + let mut event_fields = HashMap::new(); + if kind_u32 == KIND_SYSTEM_MESSAGE { + if let Ok(serde_json::Value::Object(payload)) = serde_json::from_str(&content) { + for field in ["type", "target", "actor", "role", "scope"] { + if let Some(value) = payload.get(field).and_then(|value| value.as_str()) { + let key = if field == "target" { + "member_pubkey" + } else { + field + }; + event_fields.insert(key.to_owned(), value.to_owned()); + } + } + } + } + executor::TriggerContext { text: content, author, @@ -1018,7 +1047,7 @@ pub fn build_trigger_context(event: &buzz_core::StoredEvent) -> executor::Trigge timestamp: event.event.created_at.as_secs().to_string(), emoji, message_id, - webhook_fields: HashMap::new(), + webhook_fields: event_fields, } } @@ -1048,6 +1077,7 @@ fn trigger_matches_event(trigger: &TriggerDef, kind_u32: u32) -> bool { TriggerDef::MessagePosted { .. } => kind_u32 == KIND_STREAM_MESSAGE, TriggerDef::ReactionAdded { .. } => kind_u32 == KIND_REACTION, TriggerDef::DiffPosted { .. } => kind_u32 == KIND_STREAM_MESSAGE_DIFF, + TriggerDef::MemberJoined { .. } => kind_u32 == KIND_SYSTEM_MESSAGE, // Schedule and Webhook triggers are not fired by channel events. TriggerDef::Schedule { .. } | TriggerDef::Webhook => false, } @@ -1529,6 +1559,27 @@ steps: buzz_core::StoredEvent::new(event, Some(Uuid::new_v4())) } + fn make_member_joined_event(role: &str) -> buzz_core::StoredEvent { + use nostr::{EventBuilder, Keys, Kind}; + use uuid::Uuid; + let keys = Keys::generate(); + let content = serde_json::json!({ + "type": "member_joined", + "actor": keys.public_key().to_hex(), + "target": "ab".repeat(32), + "role": role, + "scope": "community", + }); + let event = EventBuilder::new( + Kind::Custom(KIND_SYSTEM_MESSAGE as u16), + content.to_string(), + ) + .tags([]) + .sign_with_keys(&keys) + .expect("sign"); + buzz_core::StoredEvent::new(event, Some(Uuid::new_v4())) + } + /// Create a reaction event with an `e` tag pointing to a target message. fn make_reaction_event() -> (buzz_core::StoredEvent, String) { use nostr::{EventBuilder, Keys, Kind, Tag}; @@ -1568,6 +1619,33 @@ steps: assert!(ctx.webhook_fields.is_empty()); } + #[tokio::test] + async fn member_joined_trigger_exposes_target_and_skips_bots_by_default() { + let human_ctx = build_trigger_context(&make_member_joined_event("member")); + let expected_pubkey = "ab".repeat(32); + assert_eq!(human_ctx.get_field("type"), Some("member_joined")); + assert_eq!( + human_ctx.get_field("member_pubkey"), + Some(expected_pubkey.as_str()) + ); + assert_eq!(human_ctx.get_field("role"), Some("member")); + assert_eq!(human_ctx.get_field("scope"), Some("community")); + + let human_only = WorkflowDef { + name: "Welcome".to_owned(), + description: None, + trigger: TriggerDef::MemberJoined { + include_bots: false, + }, + steps: vec![], + enabled: true, + }; + assert!(should_fire_workflow(&human_only, &human_ctx, Uuid::new_v4()).await); + + let bot_ctx = build_trigger_context(&make_member_joined_event("bot")); + assert!(!should_fire_workflow(&human_only, &bot_ctx, Uuid::new_v4()).await); + } + #[test] fn build_trigger_context_reaction_event() { let (stored, target_id_hex) = make_reaction_event(); diff --git a/crates/buzz-workflow/src/schema.rs b/crates/buzz-workflow/src/schema.rs index 9bc79aa48b..cdbc609da0 100644 --- a/crates/buzz-workflow/src/schema.rs +++ b/crates/buzz-workflow/src/schema.rs @@ -54,6 +54,12 @@ pub enum TriggerDef { #[serde(default)] filter: Option, }, + /// Fires once when a human first joins the community. + MemberJoined { + /// Also fire for members whose role is `bot`. Defaults to false. + #[serde(default)] + include_bots: bool, + }, /// Fires on a cron schedule. Schedule { /// Cron expression (UTC). Mutually exclusive with `interval`. @@ -322,6 +328,18 @@ mod tests { } } + #[test] + fn parse_member_joined_trigger() { + let yaml = "name: Welcome\ntrigger:\n on: member_joined\nsteps:\n - id: welcome\n action: send_message\n text: 'Welcome {{trigger.member_pubkey}}'\n"; + let (def, _) = parse_yaml(yaml).expect("parse failed"); + assert!(matches!( + def.trigger, + TriggerDef::MemberJoined { + include_bots: false + } + )); + } + #[test] fn parse_workflow_with_conditions() { // Use single-quoted YAML strings; evalexpr expressions use double quotes inside. diff --git a/deploy/compose/agent-entrypoint.sh b/deploy/compose/agent-entrypoint.sh index 7b0892686a..c521e1eefc 100644 --- a/deploy/compose/agent-entrypoint.sh +++ b/deploy/compose/agent-entrypoint.sh @@ -80,6 +80,46 @@ if [ ! -s "${BUZZ_AGENT_KEY_FILE}" ]; then fi export VARVIK_AGENT_PUBKEY BUZZ_PRIVATE_KEY +# Ask the configured ACP adapter for its model catalog before publishing the +# hosted directory entry. This keeps Desktop's per-agent picker capability- +# driven: Claude, Codex, and future adapters each advertise their own options +# instead of the UI maintaining a stale provider table. +if [ -z "${BUZZ_ACP_PROFILE_MODELS_JSON:-}" ]; then + raw_models="$(buzz-acp models --json 2>/dev/null || true)" + BUZZ_ACP_PROFILE_MODELS_JSON="$(printf '%s' "${raw_models}" | node -e ' + let input = ""; + process.stdin.on("data", chunk => { input += chunk; }); + process.stdin.on("end", () => { + try { + const payload = JSON.parse(input); + const found = new Map(); + for (const config of payload?.stable?.configOptions ?? []) { + for (const option of config?.options ?? []) { + if (typeof option?.value === "string") { + found.set(option.value, { + id: option.value, + name: typeof option.displayName === "string" ? option.displayName : null, + }); + } + } + } + for (const option of payload?.unstable?.availableModels ?? []) { + if (typeof option?.modelId === "string" && !found.has(option.modelId)) { + found.set(option.modelId, { + id: option.modelId, + name: typeof option.name === "string" ? option.name : null, + }); + } + } + process.stdout.write(JSON.stringify([...found.values()])); + } catch { + process.stdout.write("[]"); + } + }); + ')" +fi +export BUZZ_ACP_PROFILE_MODELS_JSON + # Local single-host bundles may let the agent perform its own idempotent member # bootstrap. Managed deployments pre-register public keys with the relay and # disable this step so agent containers never receive relay-administrator @@ -182,6 +222,10 @@ fi if [ -n "${BUZZ_ACP_PROFILE_ALIASES:-}" ]; then set -- "$@" --aliases "${BUZZ_ACP_PROFILE_ALIASES}" fi +set -- "$@" --models-json "${BUZZ_ACP_PROFILE_MODELS_JSON:-[]}" +if [ -n "${BUZZ_ACP_MODEL:-}" ]; then + set -- "$@" --model "${BUZZ_ACP_MODEL}" +fi "$@" diff --git a/deploy/compose/compose.yml b/deploy/compose/compose.yml index d1f0ce8027..bb4b707368 100644 --- a/deploy/compose/compose.yml +++ b/deploy/compose/compose.yml @@ -13,6 +13,10 @@ x-varvik-agent-environment: &varvik-agent-environment BUZZ_ACP_ALLOWED_RESPOND_TO: owner-only,anyone BUZZ_ACP_SUBSCRIBE: mentions BUZZ_ACP_LAZY_POOL: "true" + # Enables authenticated owner control frames for per-agent model changes. + BUZZ_ACP_RELAY_OBSERVER: "true" + BUZZ_ACP_AGENT_OWNER: ${RELAY_OWNER_PUBKEY:-} + BUZZ_ACP_PROFILE_OWNER_PUBKEY: ${RELAY_OWNER_PUBKEY:-} # Reject any tool action that asks to escape the runtime's normal sandbox. # Safe read/edit operations can still run inside the isolated container. BUZZ_ACP_PERMISSION_MODE: dont-ask diff --git a/desktop/package.json b/desktop/package.json index e8145f5468..bcbf20dca0 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -21,7 +21,7 @@ "test:e2e:smoke": "pnpm build:e2e && playwright test --project=smoke", "test:e2e:integration": "pnpm build:e2e && playwright test --project=integration", "test:e2e:report": "playwright show-report", - "tauri:build": "tauri build" + "tauri:build": "node ./scripts/prepare-sidecars.mjs && tauri build" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7ce7f48389..cc0ecb8a59 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -53,6 +53,8 @@ export default defineConfig({ "**/agent-readiness-screenshots.spec.ts", "**/agent-error-state-screenshots.spec.ts", "**/edit-agent.spec.ts", + "**/hosted-agent-edit.spec.ts", + "**/agent-message-summary.spec.ts", "**/doctor-cta-screenshots.spec.ts", "**/pubkey-display-screenshots.spec.ts", "**/file-attachment.spec.ts", @@ -132,6 +134,7 @@ export default defineConfig({ "**/harness-management.spec.ts", "**/harness-catalog-screenshots.spec.ts", "**/inline-custom-harness.spec.ts", + "**/where-to-run-config.spec.ts", "**/huddle-transcription.spec.ts", ], use: { diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index ee022ac5f7..52153aecb0 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -50,6 +50,25 @@ const rules = [ // Do not add to this list; split the file instead. Remove each entry as its // file is broken up. Tracked as a follow-up. const overrides = new Map([ + // Relay-hosted agents expose a compact model catalog beside their existing + // identity fields. The transport type is intentionally kept at the relay + // boundary while the catalog behavior remains in dedicated modules. + ["src-tauri/src/managed_agents/types.rs", 1010], + // Alerts navigation adds one route callback at each end of the existing + // AppSidebar composition seam; all alert behavior lives outside AppShell. + ["src/app/AppShell.tsx", 1002], + // The model selector distinguishes a harness-native runtime default from a + // concrete inherited model. The selector implementation remains shared. + ["src/features/agents/ui/AgentConfigFields.tsx", 1001], + // Persisted workspace/project groups and alert routing extend the sidebar's + // composition layer. Sorting and group partitioning remain split into + // defaultChannelGroups, with the broader sidebar decomposition still queued. + ["src/features/sidebar/ui/AppSidebar.tsx", 1166], + // Hosted-agent identity resolution now covers directory-backed candidates, + // exact-name mentions, and explicit relay-agent routing in one existing + // composition hook. Keep the ratchet exact while that hook is split into a + // pure mention-resolution module in the follow-up decomposition. + ["src/features/messages/lib/useMentions.ts", 1087], // Native Builderlab auth/community commands add a small registration surface // to the existing Tauri composition root. The implementation lives in // builderlab.rs; this narrowly ratchets the command wiring while lib.rs is @@ -443,7 +462,10 @@ const overrides = new Map([ ["src/features/profile/ui/UserProfilePanelSections.tsx", 1140], // +14 for openEditAgent event subscription (config-nudge card "Open Edit Agent" action). // +11 for editAgentFocus state + initialFocus prop threading (deep-link granularity). - ["src/features/profile/ui/UserProfilePanel.tsx", 1025], + // Hosted-agent presentation and edit authorization add the profile-level + // bridge for relay agents. The dialog implementation remains split out in + // HostedAgentEditDialog; this file only owns the profile integration seam. + ["src/features/profile/ui/UserProfilePanel.tsx", 1029], // PersistBackend enum + marker-on-keyring-success plumbing and its three // fail-closed regression tests (silent identity rotation on keyring outage). // A small overage from load-bearing security plumbing on a file already at @@ -680,7 +702,9 @@ const overrides = new Map([ // hidden-key projection keeps the top-level secret out of Advanced rows. // +6 (1195 -> 1201): rebase onto main — this PR's model-source label wiring // lands on top of main's dialog growth. Queued to split. - ["src/features/agents/ui/AgentInstanceEditDialog.tsx", 1229], + // Instance avatar overrides complete the existing profile edit flow. The + // picker itself remains in AgentCreationPreview; this is submit/render wiring. + ["src/features/agents/ui/AgentInstanceEditDialog.tsx", 1235], // AgentDefinitionDialog grew past 1000 with the following load-bearing fixes: // isRuntimeAutoSeededRef tracking for edit-mode seeding (Fizz shows models); // runtimeSupportsLlmProviderSelection guard on discovery provider (codex fix); diff --git a/desktop/scripts/prepare-sidecars.mjs b/desktop/scripts/prepare-sidecars.mjs new file mode 100644 index 0000000000..97a122fcb4 --- /dev/null +++ b/desktop/scripts/prepare-sidecars.mjs @@ -0,0 +1,80 @@ +import { copyFileSync, mkdirSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const desktopDir = resolve(scriptDir, ".."); +const repoDir = resolve(desktopDir, ".."); +const binariesDir = join(desktopDir, "src-tauri", "binaries"); +const packages = [ + ["buzz-acp", "buzz-acp"], + ["buzz-agent", "buzz-agent"], + ["buzz-dev-mcp", "buzz-dev-mcp"], + ["git-credential-nostr", "git-credential-nostr"], + ["buzz-cli", "buzz"], +]; + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: repoDir, + env: process.env, + stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function capture(command, args) { + const result = spawnSync(command, args, { + cwd: repoDir, + encoding: "utf8", + env: process.env, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + process.stderr.write(result.stderr ?? ""); + process.exit(result.status ?? 1); + } + return result.stdout; +} + +const rustcInfo = capture("rustc", ["-vV"]); +const hostLine = rustcInfo + .split(/\r?\n/) + .find((line) => line.startsWith("host: ")); +const host = hostLine?.slice("host: ".length).trim(); +if (!host) throw new Error("Could not determine the Rust host target."); + +run("cargo", [ + "build", + "--release", + ...packages.flatMap(([packageName]) => ["-p", packageName]), +]); + +const metadata = JSON.parse( + capture("cargo", ["metadata", "--format-version", "1", "--no-deps"]), +); +const extension = host.includes("windows") ? ".exe" : ""; +mkdirSync(binariesDir, { recursive: true }); + +for (const [, binaryName] of packages) { + const source = join( + metadata.target_directory, + "release", + `${binaryName}${extension}`, + ); + const destination = join(binariesDir, `${binaryName}-${host}${extension}`); + const sourceSize = statSync(source).size; + if (sourceSize === 0) { + throw new Error(`Refusing to bundle empty sidecar: ${source}`); + } + copyFileSync(source, destination); + if (statSync(destination).size !== sourceSize) { + throw new Error(`Sidecar copy verification failed: ${destination}`); + } +} + +console.log(`Prepared ${packages.length} non-empty sidecars for ${host}.`); diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6d393d1b68..fb075f968b 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -145,3 +145,13 @@ tokio = { version = "1", features = ["test-util"] } # The relay's media validation, so the snapshot-sharing tests can prove the # full export → sanitize → relay-accept → import contract end to end. buzz_media_pkg = { package = "buzz-media", path = "../../crates/buzz-media" } + +# Dev profile — see the matching block in the root workspace Cargo.toml. This +# is a separate workspace, so it needs its own copy: profiles only apply from +# the workspace root, and src-tauri compiles its own duplicate of every shared +# dependency. Without this the desktop `target/` alone reaches ~21 GB. +[profile.dev] +debug = true + +[profile.dev.package."*"] +debug = false diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index cbbf4ce351..161e3cbbaf 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -333,10 +333,7 @@ fn install_acp_runtime_blocking( // For the codex runtime, "found" is not enough — the resolved binary must also // pass the 1.x version gate. An outdated 0.16.x adapter must be overwritten by // the new npm install so the CODEX_CONFIG spawn contract works correctly. - let adapter_path = runtime - .commands - .iter() - .find_map(|cmd| crate::managed_agents::resolve_command(cmd)); + let adapter_path = resolve_adapter_path(runtime.commands, runtime.adapter_install_commands); let adapter_probe_path = crate::managed_agents::readiness::cli_probe::augmented_path(); if let Some(cmds) = plan_adapter_install( runtime_id, @@ -1020,7 +1017,7 @@ use install_report::InstallReporter; mod managed_node; use managed_node::{ ensure_managed_node_runtime_blocking, managed_node_runtime_supported, managed_npm_command, - npm_eacces_hint, + npm_eacces_hint, resolve_adapter_path, }; #[tauri::command] @@ -1064,11 +1061,126 @@ pub async fn list_relay_agents(state: State<'_, AppState>) -> Result` the frontend expects. let value = nostr_convert::agents_from_events(&events); - let agents = value - .get("agents") - .cloned() - .unwrap_or_else(|| serde_json::json!([])); - serde_json::from_value(agents).map_err(|e| format!("agent parse failed: {e}")) + let mut agents: Vec = serde_json::from_value( + value + .get("agents") + .cloned() + .unwrap_or_else(|| serde_json::json!([])), + ) + .map_err(|e| format!("agent parse failed: {e}"))?; + + // Hosted agents own their kind:10100 directory event, so an administrator + // cannot safely rewrite it. Merge public, admin-authored parameterized + // projections instead. Every client must see the same authorized head. + let config_events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [ + buzz_core_pkg::kind::KIND_HOSTED_AGENT_CONFIG, + buzz_core_pkg::kind::KIND_MANAGED_AGENT, + ], + })], + ) + .await?; + let membership_events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [buzz_core_pkg::kind::KIND_NIP43_MEMBERSHIP_LIST], + "limit": 1, + })], + ) + .await?; + let admin_pubkeys: std::collections::HashSet = membership_events + .first() + .map(|event| { + event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + let role = parts.get(2).map(String::as_str).unwrap_or("member"); + (parts.first().map(String::as_str) == Some("member") + && matches!(role, "owner" | "admin")) + .then(|| parts.get(1).cloned()) + .flatten() + }) + .collect() + }) + .unwrap_or_default(); + let mut latest_config_at = std::collections::HashMap::::new(); + + for event in config_events { + let d_tag = event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("d")) + .then(|| parts.get(1).cloned()) + .flatten() + }); + let Some(d_tag) = d_tag else { + continue; + }; + let Ok(config) = serde_json::from_str::(&event.content) else { + continue; + }; + let is_compat_projection = + event.kind.as_u16() as u32 == buzz_core_pkg::kind::KIND_MANAGED_AGENT; + if is_compat_projection + && (config.get("schema").and_then(serde_json::Value::as_str) + != Some("buzz.hosted-agent-config.v1") + || !d_tag.starts_with("hosted-agent:")) + { + continue; + } + let agent_pubkey = config + .get("agent_pubkey") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| d_tag.strip_prefix("hosted-agent:").unwrap_or(&d_tag)) + .to_string(); + let Some(agent) = agents + .iter_mut() + .find(|agent| agent.pubkey.eq_ignore_ascii_case(&agent_pubkey)) + else { + continue; + }; + let author = event.pubkey.to_hex(); + let declared_owner = agent.owner_pubkey.as_deref().unwrap_or_default(); + if !admin_pubkeys.contains(&author) && !declared_owner.eq_ignore_ascii_case(&author) { + continue; + } + let created_at = event.created_at.as_secs(); + if latest_config_at + .get(&agent_pubkey) + .is_some_and(|latest| *latest > created_at) + { + continue; + } + latest_config_at.insert(agent_pubkey.clone(), created_at); + if let Some(name) = config + .get("name") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + agent.name = name.to_string(); + } + if let Some(avatar_url) = config.get("avatar_url") { + agent.avatar_url = avatar_url + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + } + if let Some(model) = config.get("model") { + agent.model = model + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + } + } + + Ok(agents) } #[cfg(test)] @@ -1741,7 +1853,7 @@ mod tests { #[test] fn test_powershell_command_argv_exact() { // Catalog format: body wrapped in one outer double-quote pair (Bash-layer serialization). - let body = "irm https://chatgpt.com/codex/install.ps1 | iex"; + let body = "$ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-codex.ps1'; Invoke-RestMethod https://chatgpt.com/codex/install.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE"; let cmd = super::install_powershell_command(&format!( r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "{body}""# )); @@ -1771,12 +1883,12 @@ mod tests { ); } - /// Claude Code catalog command (discovery.rs:107) must dequote to the bare pipeline. + /// Claude Code catalog command must dequote to the two-step download-then-execute body. #[cfg(windows)] #[test] fn test_powershell_command_claude_catalog_dequoted() { let cmd = super::install_powershell_command( - r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "irm https://claude.ai/install.ps1 | iex""#, + r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-claude.ps1'; Invoke-RestMethod https://claude.ai/install.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE""#, ); assert_eq!( cmd.get_args() @@ -1787,22 +1899,22 @@ mod tests { "-ExecutionPolicy", "Bypass", "-Command", - "irm https://claude.ai/install.ps1 | iex", + "$ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-claude.ps1'; Invoke-RestMethod https://claude.ai/install.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE", ], "Claude catalog command must be dequoted correctly" ); } - /// Goose Windows catalog command (discovery.rs:78) must dequote to a bare pipeline - /// with a literal `$env:` prefix — no backslash before the dollar sign. - /// This proves the `\$` → `$` escape fix: post-#2750 the spawn is native and + /// Goose Windows catalog command must dequote to the two-step download-then-execute body + /// with the `$env:CONFIGURE` prefix intact — no backslash before the dollar sign. + /// This proves the `\$` → `$` contract: post-#2750 the spawn is native and /// PowerShell receives the body verbatim, so a residual `\` would produce /// `\$env:CONFIGURE='false'` which is a malformed statement. #[cfg(windows)] #[test] fn test_powershell_command_goose_catalog_dequoted() { let cmd = super::install_powershell_command( - r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex""#, + r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$env:CONFIGURE='false'; $ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-goose.ps1'; Invoke-RestMethod https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE""#, ); assert_eq!( cmd.get_args() @@ -1813,7 +1925,7 @@ mod tests { "-ExecutionPolicy", "Bypass", "-Command", - "$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex", + "$env:CONFIGURE='false'; $ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-goose.ps1'; Invoke-RestMethod https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE", ], "Goose catalog command must dequote with bare $env: (no backslash before $)" ); diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs index 72108f0291..fbfb068c0e 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs @@ -102,25 +102,155 @@ fn managed_node_failed_step(stderr: String) -> InstallStepResult { } } -fn managed_node_runtime_ready() -> bool { +pub(super) fn managed_node_runtime_ready() -> bool { let Some(node) = crate::managed_agents::buzz_managed_node_bin_path() else { return false; }; if !node.is_file() { return false; } - let mut cmd = std::process::Command::new(&node); + probe_node(&node, MANAGED_NODE_VERSION, Duration::from_secs(3)) +} + +/// Run `executable --version` with a bounded deadline and return `true` only +/// when it exits 0 and its trimmed stdout equals `expected_version`. +/// +/// Transport: stdout is redirected to a temp file so no exit path can block on +/// an inherited handle (a descendant retaining a pipe write-end would otherwise +/// prevent EOF indefinitely). +/// +/// Cleanup: the child runs in its own process group on Unix (`process_group(0)`) +/// so an unconditional group SIGKILL on every exit path terminates all +/// descendants. On Windows, `terminate_process` issues `taskkill /T /F` for +/// tree-wide cleanup. SIGKILL to an already-dead group returns ESRCH (no-op). +pub(super) fn probe_node( + executable: &std::path::Path, + expected_version: &str, + timeout: Duration, +) -> bool { + let tmp = match tempfile::NamedTempFile::new() { + Ok(f) => f, + Err(_) => return false, + }; + let out_file = match tmp.reopen() { + Ok(f) => f, + Err(_) => return false, + }; + + let mut cmd = std::process::Command::new(executable); cmd.arg("--version") .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) + .stdout(std::process::Stdio::from(out_file)) .stderr(std::process::Stdio::null()); crate::util::configure_no_window(&mut cmd); - let output = cmd.output(); - output - .ok() - .filter(|output| output.status.success()) - .map(|output| String::from_utf8_lossy(&output.stdout).trim() == MANAGED_NODE_VERSION) - .unwrap_or(false) + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + let Ok(mut child) = cmd.spawn() else { + return false; + }; + + let deadline = std::time::Instant::now() + timeout; + let exit_status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => { + if std::time::Instant::now() >= deadline { + kill_probe_group(child.id()); + let _ = child.wait(); + return false; + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(_) => { + kill_probe_group(child.id()); + let _ = child.wait(); + return false; + } + } + }; + + // Group-kill unconditionally: SIGKILL to a dead group is ESRCH (no-op). + kill_probe_group(child.id()); + + if !exit_status.success() { + return false; + } + + let mut output = String::new(); + if std::io::Read::read_to_string(&mut tmp.as_file(), &mut output).is_err() { + return false; + } + output.trim() == expected_version +} + +/// Kill the probe's process group/tree unconditionally (no TERM grace — this +/// is a probe, not an agent session). ESRCH on a dead group is fine. +fn kill_probe_group(pid: u32) { + #[cfg(unix)] + unsafe { + libc::kill(-(pid as i32), libc::SIGKILL); + } + #[cfg(windows)] + { + let _ = crate::managed_agents::terminate_process(pid); + } + #[cfg(not(any(unix, windows)))] + { + let _ = pid; + } +} + +/// Returns `true` when the managed Node runtime is absent or no longer executes — +/// meaning any existing npm adapter shims are broken and must be reinstalled. +/// +/// This fires when the pinned Node version changes (e.g. v24.11.0 → v24.18.0): +/// the old dir stays on disk, shims appear installed, but they fail at run time +/// because the Node binary they reference is gone. Treating the adapter as +/// missing forces `ensure_managed_node_runtime_blocking` to re-download Node and +/// npm to reinstall the shims. +pub(super) fn managed_node_orphaned() -> bool { + managed_node_runtime_supported() && !managed_node_runtime_ready() +} + +/// Returns `true` when an adapter at `resolved` should be invalidated. +/// +/// Only a Buzz-managed shim (path under `managed_prefix`) with an orphaned +/// runtime is invalidated; external adapters are always preserved. +pub(super) fn should_invalidate_adapter( + resolved: &std::path::Path, + managed_prefix: &std::path::Path, + orphaned: bool, +) -> bool { + orphaned && resolved.starts_with(managed_prefix) +} + +/// Resolve the adapter binary path, accounting for the Node-orphan case. +/// Resolves first; invalidates only managed-prefix shims when Node is orphaned. +pub(super) fn resolve_adapter_path( + commands: &[&str], + adapter_install_commands: &[&str], +) -> Option { + let resolved = commands + .iter() + .find_map(|cmd| crate::managed_agents::resolve_command(cmd)); + + let needs_managed_npm = adapter_install_commands + .iter() + .any(|cmd| is_npm_global_install(cmd)); + if needs_managed_npm { + if let (Some(ref path), Some(ref managed_bin)) = + (&resolved, crate::managed_agents::buzz_managed_npm_bin_dir()) + { + if should_invalidate_adapter(path, managed_bin, managed_node_orphaned()) { + return None; + } + } + } + + resolved } fn managed_node_install_lock() -> &'static Mutex<()> { @@ -538,211 +668,5 @@ pub(super) fn npm_eacces_hint(stderr: &str, _command: &str) -> Option { // ── end managed npm adapter installs ────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_npm_eacces_hint_guidance_mentions_buzz_private_dir() { - let hint = npm_eacces_hint("EACCES: permission denied", "npm install -g foo").unwrap(); - assert!( - hint.contains("Buzz's private Node tools directory"), - "hint: {hint}" - ); - } - - #[test] - fn test_rewrite_npm_install_uses_private_prefix() { - assert_eq!( - rewrite_npm_global_install( - "npm install -g @agentclientprotocol/codex-acp", - "'/tmp/Buzz Node'" - ), - "npm install --global --prefix '/tmp/Buzz Node' @agentclientprotocol/codex-acp" - ); - } - - #[test] - fn test_rewrite_npm_i_uses_private_prefix() { - assert_eq!( - rewrite_npm_global_install("npm i -g some-package", "'/tmp/buzz'"), - "npm i --global --prefix '/tmp/buzz' some-package" - ); - } - - #[test] - fn test_rewrite_npm_uninstall_uses_private_prefix() { - assert_eq!( - rewrite_npm_global_install("npm uninstall -g @zed-industries/codex-acp", "'/tmp/buzz'"), - "npm uninstall --global --prefix '/tmp/buzz' @zed-industries/codex-acp" - ); - } - - #[test] - fn test_rewrite_ignores_non_global_command() { - assert_eq!( - rewrite_npm_global_install("npm install foo", "'/tmp/buzz'"), - "npm install foo" - ); - } - - #[test] - fn test_shell_quote_escapes_single_quotes() { - assert_eq!( - shell_quote(std::path::Path::new("/tmp/Buzz's Node")), - "'/tmp/Buzz'\\''s Node'" - ); - } - - // ── zip validation tests ────────────────────────────────────────────────── - - /// Build an in-memory zip archive with the supplied entry names and return - /// a temporary file containing it (zip::ZipArchive requires Seek). - fn make_zip_with_entries(entry_names: &[&str]) -> tempfile::NamedTempFile { - let mut buf: Vec = Vec::new(); - { - let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf)); - let opts = zip::write::SimpleFileOptions::default(); - for name in entry_names { - writer.start_file(*name, opts).unwrap(); - } - writer.finish().unwrap(); - } - let mut tmp = tempfile::NamedTempFile::new().unwrap(); - std::io::Write::write_all(&mut tmp, &buf).unwrap(); - tmp - } - - #[test] - fn test_validate_zip_accepts_normal_entries() { - let tmp = make_zip_with_entries(&[ - "node-v24.18.0-win-x64/node.exe", - "node-v24.18.0-win-x64/npm.cmd", - "node-v24.18.0-win-x64/npm", - ]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - assert!(validate_managed_node_zip_entries(&archive).is_ok()); - } - - #[test] - fn test_validate_zip_rejects_absolute_path() { - let tmp = make_zip_with_entries(&["/etc/passwd"]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - let err = validate_managed_node_zip_entries(&archive).unwrap_err(); - assert!( - err.contains("absolute path"), - "expected 'absolute path' in: {err}" - ); - } - - #[test] - fn test_validate_zip_rejects_path_traversal() { - let tmp = make_zip_with_entries(&["../../../etc/passwd"]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - let err = validate_managed_node_zip_entries(&archive).unwrap_err(); - assert!( - err.contains("path traversal"), - "expected 'path traversal' in: {err}" - ); - } - - #[test] - fn test_validate_zip_rejects_backslash_rooted() { - // Windows-style absolute path using backslash — must reject on every host. - let tmp = make_zip_with_entries(&["\\Windows\\system32\\evil.dll"]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - let err = validate_managed_node_zip_entries(&archive).unwrap_err(); - assert!( - err.contains("absolute path"), - "expected 'absolute path' in: {err}" - ); - } - - #[test] - fn test_validate_zip_rejects_drive_prefix() { - // Windows drive-letter absolute path — must reject on every host. - let tmp = make_zip_with_entries(&["C:\\evil\\payload.exe"]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - let err = validate_managed_node_zip_entries(&archive).unwrap_err(); - assert!( - err.contains("absolute path"), - "expected 'absolute path' in: {err}" - ); - } - - #[test] - fn test_validate_zip_rejects_backslash_traversal() { - // Path traversal using Windows separator — must reject on every host. - let tmp = make_zip_with_entries(&["node-v24.18.0-win-x64\\..\\..\\evil"]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - let err = validate_managed_node_zip_entries(&archive).unwrap_err(); - assert!( - err.contains("path traversal"), - "expected 'path traversal' in: {err}" - ); - } - - // ── verify_node_tree layout tests ───────────────────────────────────────── - - #[test] - fn test_verify_node_tree_unix_layout_passes() { - let tmp = tempfile::TempDir::new().unwrap(); - let bin = tmp.path().join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - std::fs::write(bin.join("node"), b"").unwrap(); - std::fs::write(bin.join("npm"), b"").unwrap(); - // On non-Windows the unix branch is active — this must pass. - #[cfg(not(windows))] - assert!(verify_node_tree(tmp.path()).is_ok()); - // On Windows the windows branch is active — unix layout must fail. - #[cfg(windows)] - assert!(verify_node_tree(tmp.path()).is_err()); - } - - #[test] - fn test_verify_node_tree_unix_layout_missing_npm_fails() { - let tmp = tempfile::TempDir::new().unwrap(); - let bin = tmp.path().join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - std::fs::write(bin.join("node"), b"").unwrap(); - // npm intentionally absent - #[cfg(not(windows))] - { - let err = verify_node_tree(tmp.path()).unwrap_err(); - assert!(err.contains("bin/npm"), "err: {err}"); - } - } - - #[test] - fn test_verify_node_tree_windows_layout_passes() { - let tmp = tempfile::TempDir::new().unwrap(); - std::fs::write(tmp.path().join("node.exe"), b"").unwrap(); - std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap(); - std::fs::write(tmp.path().join("npm"), b"").unwrap(); - // On Windows the windows branch is active — this must pass. - #[cfg(windows)] - assert!(verify_node_tree(tmp.path()).is_ok()); - // On non-Windows the unix branch is active — windows-layout root files - // don't satisfy bin/node + bin/npm, so this must fail. - #[cfg(not(windows))] - assert!(verify_node_tree(tmp.path()).is_err()); - } - - #[test] - fn test_verify_node_tree_windows_layout_missing_npm_shim_fails() { - let tmp = tempfile::TempDir::new().unwrap(); - std::fs::write(tmp.path().join("node.exe"), b"").unwrap(); - std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap(); - // npm POSIX shim intentionally absent - #[cfg(windows)] - { - let err = verify_node_tree(tmp.path()).unwrap_err(); - assert!(err.contains("npm"), "err: {err}"); - } - } -} +#[path = "managed_node_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs new file mode 100644 index 0000000000..a8e1d7f4c8 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs @@ -0,0 +1,481 @@ +use super::*; + +#[test] +fn test_npm_eacces_hint_guidance_mentions_buzz_private_dir() { + let hint = npm_eacces_hint("EACCES: permission denied", "npm install -g foo").unwrap(); + assert!( + hint.contains("Buzz's private Node tools directory"), + "hint: {hint}" + ); +} + +#[test] +fn test_rewrite_npm_install_uses_private_prefix() { + assert_eq!( + rewrite_npm_global_install( + "npm install -g @agentclientprotocol/codex-acp", + "'/tmp/Buzz Node'" + ), + "npm install --global --prefix '/tmp/Buzz Node' @agentclientprotocol/codex-acp" + ); +} + +#[test] +fn test_rewrite_npm_i_uses_private_prefix() { + assert_eq!( + rewrite_npm_global_install("npm i -g some-package", "'/tmp/buzz'"), + "npm i --global --prefix '/tmp/buzz' some-package" + ); +} + +#[test] +fn test_rewrite_npm_uninstall_uses_private_prefix() { + assert_eq!( + rewrite_npm_global_install("npm uninstall -g @zed-industries/codex-acp", "'/tmp/buzz'"), + "npm uninstall --global --prefix '/tmp/buzz' @zed-industries/codex-acp" + ); +} + +#[test] +fn test_rewrite_ignores_non_global_command() { + assert_eq!( + rewrite_npm_global_install("npm install foo", "'/tmp/buzz'"), + "npm install foo" + ); +} + +#[test] +fn test_shell_quote_escapes_single_quotes() { + assert_eq!( + shell_quote(std::path::Path::new("/tmp/Buzz's Node")), + "'/tmp/Buzz'\\''s Node'" + ); +} + +// ── zip validation tests ────────────────────────────────────────────────────── + +/// Build an in-memory zip archive with the supplied entry names and return +/// a temporary file containing it (zip::ZipArchive requires Seek). +fn make_zip_with_entries(entry_names: &[&str]) -> tempfile::NamedTempFile { + let mut buf: Vec = Vec::new(); + { + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf)); + let opts = zip::write::SimpleFileOptions::default(); + for name in entry_names { + writer.start_file(*name, opts).unwrap(); + } + writer.finish().unwrap(); + } + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut tmp, &buf).unwrap(); + tmp +} + +#[test] +fn test_validate_zip_accepts_normal_entries() { + let tmp = make_zip_with_entries(&[ + "node-v24.18.0-win-x64/node.exe", + "node-v24.18.0-win-x64/npm.cmd", + "node-v24.18.0-win-x64/npm", + ]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + assert!(validate_managed_node_zip_entries(&archive).is_ok()); +} + +#[test] +fn test_validate_zip_rejects_absolute_path() { + let tmp = make_zip_with_entries(&["/etc/passwd"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("absolute path"), + "expected 'absolute path' in: {err}" + ); +} + +#[test] +fn test_validate_zip_rejects_path_traversal() { + let tmp = make_zip_with_entries(&["../../../etc/passwd"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("path traversal"), + "expected 'path traversal' in: {err}" + ); +} + +#[test] +fn test_validate_zip_rejects_backslash_rooted() { + // Windows-style absolute path using backslash — must reject on every host. + let tmp = make_zip_with_entries(&["\\Windows\\system32\\evil.dll"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("absolute path"), + "expected 'absolute path' in: {err}" + ); +} + +#[test] +fn test_validate_zip_rejects_drive_prefix() { + // Windows drive-letter absolute path — must reject on every host. + let tmp = make_zip_with_entries(&["C:\\evil\\payload.exe"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("absolute path"), + "expected 'absolute path' in: {err}" + ); +} + +#[test] +fn test_validate_zip_rejects_backslash_traversal() { + // Path traversal using Windows separator — must reject on every host. + let tmp = make_zip_with_entries(&["node-v24.18.0-win-x64\\..\\..\\evil"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("path traversal"), + "expected 'path traversal' in: {err}" + ); +} + +// ── verify_node_tree layout tests ───────────────────────────────────────────── + +#[test] +fn test_verify_node_tree_unix_layout_passes() { + let tmp = tempfile::TempDir::new().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("node"), b"").unwrap(); + std::fs::write(bin.join("npm"), b"").unwrap(); + // On non-Windows the unix branch is active — this must pass. + #[cfg(not(windows))] + assert!(verify_node_tree(tmp.path()).is_ok()); + // On Windows the windows branch is active — unix layout must fail. + #[cfg(windows)] + assert!(verify_node_tree(tmp.path()).is_err()); +} + +#[test] +fn test_verify_node_tree_unix_layout_missing_npm_fails() { + let tmp = tempfile::TempDir::new().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("node"), b"").unwrap(); + // npm intentionally absent + #[cfg(not(windows))] + { + let err = verify_node_tree(tmp.path()).unwrap_err(); + assert!(err.contains("bin/npm"), "err: {err}"); + } +} + +#[test] +fn test_verify_node_tree_windows_layout_passes() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("node.exe"), b"").unwrap(); + std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap(); + std::fs::write(tmp.path().join("npm"), b"").unwrap(); + // On Windows the windows branch is active — this must pass. + #[cfg(windows)] + assert!(verify_node_tree(tmp.path()).is_ok()); + // On non-Windows the unix branch is active — windows-layout root files + // don't satisfy bin/node + bin/npm, so this must fail. + #[cfg(not(windows))] + assert!(verify_node_tree(tmp.path()).is_err()); +} + +#[test] +fn test_verify_node_tree_windows_layout_missing_npm_shim_fails() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("node.exe"), b"").unwrap(); + std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap(); + // npm POSIX shim intentionally absent + #[cfg(windows)] + { + let err = verify_node_tree(tmp.path()).unwrap_err(); + assert!(err.contains("npm"), "err: {err}"); + } +} + +// ── should_invalidate_adapter / orphan policy pure unit tests ───────────────── + +#[test] +fn test_should_invalidate_adapter_invalidates_managed_shim_when_orphaned() { + let prefix = std::path::Path::new("/managed/npm/bin"); + let shim = prefix.join("codex-acp"); + assert!( + should_invalidate_adapter(&shim, prefix, true), + "managed shim + orphaned runtime must be invalidated" + ); +} + +#[test] +fn test_should_invalidate_adapter_keeps_external_adapter_when_orphaned() { + let prefix = std::path::Path::new("/managed/npm/bin"); + let external = std::path::Path::new("/usr/local/bin/codex-acp"); + assert!( + !should_invalidate_adapter(external, prefix, true), + "external adapter must not be invalidated even when Node is orphaned" + ); +} + +#[test] +fn test_should_invalidate_adapter_keeps_managed_shim_when_node_healthy() { + let prefix = std::path::Path::new("/managed/npm/bin"); + let shim = prefix.join("codex-acp"); + assert!( + !should_invalidate_adapter(&shim, prefix, false), + "managed shim must not be invalidated when Node is healthy" + ); +} + +#[test] +fn test_resolve_adapter_path_returns_none_when_binary_absent() { + let commands: &[&str] = &["nonexistent-buzz-test-binary-xyz"]; + let adapter_install_commands: &[&str] = &["curl -fsSL https://example.com | bash"]; + assert!( + resolve_adapter_path(commands, adapter_install_commands).is_none(), + "must return None when the command is not on PATH" + ); +} + +// ── probe_node seam regressions ─────────────────────────────────────────────── +// +// All four scenarios drive probe_node() directly — the same +// tempfile/deadline/cleanup/status/version path used by managed_node_runtime_ready. +// Each test CAN fail if production: +// - drops process_group(0) → descendant-holds-stdout assertion (d) fails +// (tempfile transport still returns promptly; +// the sleep survives and kill($!,0) returns 0) +// - skips group-kill on a path → hung-binary test exceeds margin +// - ignores exit_status.success() → nonzero-exit test returns true +// - skips version comparison → wrong-version test returns true +// +// Script files are written into a TempDir (no open write fd at spawn time) +// to avoid ETXTBSY on Linux. + +/// Scenario 1 — descendant holds stdout write-end, direct child exits immediately. +/// +/// The script backgrounds a 60-second sleep (inheriting stdout), records both the +/// script's own PID (`$$`) and the sleep's PID (`$!`) to sidecar files, then exits +/// with the expected version string. Four assertions: +/// (a) bounded return — would hang ~60 s if tempfile transport regressed to pipe; +/// (b) correct result; +/// (c) process group dead after return — catches skipped-cleanup-on-success: if +/// the group-kill is absent but `process_group(0)` is still present, a live +/// member in the group is detectable; +/// (d) descendant PID dead after return — catches dropped `process_group(0)`: if +/// the call is removed the sleep stays in the runner's group (not the probe's), +/// `kill(-pgid,0)` is vacuously ESRCH, but `kill(desc_pid,0)` returns 0 and +/// this assertion fails. This is the canonical mutation for (c). +#[cfg(unix)] +#[test] +fn test_probe_node_descendant_holds_stdout_returns_promptly_and_kills_group() { + use std::os::unix::fs::PermissionsExt; + let tmp_dir = tempfile::TempDir::new().unwrap(); + let script = tmp_dir.path().join("probe.sh"); + let pgid_file = tmp_dir.path().join("pgid"); + let desc_pid_file = tmp_dir.path().join("desc_pid"); + let pgid_file_path = pgid_file.to_str().unwrap().to_owned(); + let desc_pid_file_path = desc_pid_file.to_str().unwrap().to_owned(); + // Line 1 of script: record the script's own PID (= PGID after process_group(0)). + // Line 2: background the sleep and record its PID. + // Line 3: emit the expected version and exit so the direct child exits promptly. + let script_content = format!( + "#!/bin/sh\necho $$ > {pgid_file_path}\n/bin/sleep 60 &\necho $! > {desc_pid_file_path}\necho v24.18.0\nexit 0\n" + ); + std::fs::write(&script, script_content.as_bytes()).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let probe_timeout = std::time::Duration::from_secs(3); + let t = std::time::Instant::now(); + let result = probe_node(&script, "v24.18.0", probe_timeout); + let elapsed = t.elapsed(); + + // Give the group-kill a moment to propagate before checking liveness. + std::thread::sleep(std::time::Duration::from_millis(200)); + + // (a) bounded return — tempfile transport must not hang on the descendant's + // retained pipe write-end. + assert!( + elapsed < probe_timeout + std::time::Duration::from_secs(2), + "probe_node hung — likely descendant retained pipe write-end: elapsed {elapsed:?}" + ); + // (b) correct result + assert!(result, "probe_node must return true for matching version"); + + // (c) process group dead — catches skipped-cleanup: if the group-kill on the + // success path is removed while process_group(0) is still present, the + // sleep remains in the probe's group and kill(-pgid,0) returns 0. + let pgid_str = std::fs::read_to_string(&pgid_file) + .expect("script must have written its PID to the pgid sidecar"); + let pgid: i32 = pgid_str + .trim() + .parse() + .expect("pgid sidecar must contain a numeric PID"); + let group_alive = unsafe { libc::kill(-pgid, 0) } == 0; + assert!( + !group_alive, + "process group {pgid} must be dead after probe_node" + ); + + // (d) descendant PID dead — catches dropped process_group(0): without that + // call the sleep is never in the probe's group, so kill(-pgid,0) is + // vacuously ESRCH while the sleep survives. Asserting the descendant's + // own PID is dead proves the sleep was actually killed. + let desc_pid_str = std::fs::read_to_string(&desc_pid_file) + .expect("script must have written the sleep PID to the desc_pid sidecar"); + let desc_pid: libc::pid_t = desc_pid_str + .trim() + .parse() + .expect("desc_pid sidecar must contain a numeric PID"); + // Pre-assert cleanup: if the descendant is somehow still alive, kill it so + // a failing test does not leave a 60-second sleep in the runner's process group. + let desc_alive = unsafe { libc::kill(desc_pid, 0) } == 0; + if desc_alive { + unsafe { libc::kill(desc_pid, libc::SIGKILL) }; + } + assert!( + !desc_alive, + "descendant PID {desc_pid} must be dead after probe_node — \ + if process_group(0) is dropped, the sleep escapes into the runner's group \ + and is never killed by the group-kill" + ); +} + +/// Scenario 2 — direct hang: probe_node must traverse the real try_wait +/// deadline and return false. Does NOT call kill_probe_group directly. +/// +/// This test FAILS if the deadline loop in probe_node is broken or if the +/// timeout/kill path is not exercised (e.g., missing group-kill exits the +/// loop early via a different mechanism). +#[cfg(unix)] +#[test] +fn test_probe_node_times_out_on_hung_binary() { + use std::os::unix::fs::PermissionsExt; + let tmp_dir = tempfile::TempDir::new().unwrap(); + let script = tmp_dir.path().join("hung.sh"); + std::fs::write(&script, b"#!/bin/sh\n/bin/sleep 30\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let probe_timeout = std::time::Duration::from_secs(3); + let t = std::time::Instant::now(); + let result = probe_node(&script, "v24.18.0", probe_timeout); + let elapsed = t.elapsed(); + + assert!(!result, "probe_node must return false for a hung binary"); + // Must have traversed the deadline (not returned early via a bug). + assert!( + elapsed >= probe_timeout, + "probe_node returned before deadline: {elapsed:?} < {probe_timeout:?}" + ); + // Must not hang past the deadline by more than the poll interval + margin. + assert!( + elapsed < probe_timeout + std::time::Duration::from_secs(3), + "probe_node exceeded deadline by too much: {elapsed:?}" + ); +} + +/// Scenario 3 — non-zero exit: probe_node must return false even when stdout +/// contains the expected version string. +/// +/// This test FAILS if probe_node skips or inverts the exit_status.success() check. +#[cfg(unix)] +#[test] +fn test_probe_node_returns_false_on_nonzero_exit() { + use std::os::unix::fs::PermissionsExt; + let tmp_dir = tempfile::TempDir::new().unwrap(); + let script = tmp_dir.path().join("fail.sh"); + // Prints the expected version string but exits non-zero. + std::fs::write(&script, b"#!/bin/sh\necho v24.18.0\nexit 1\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let result = probe_node(&script, "v24.18.0", std::time::Duration::from_secs(3)); + assert!( + !result, + "probe_node must return false when the process exits non-zero" + ); +} + +/// Scenario 4 — wrong version output: probe_node must return false when stdout +/// does not match expected_version. +/// +/// This test FAILS if probe_node skips or incorrectly performs the version comparison. +#[cfg(unix)] +#[test] +fn test_probe_node_returns_false_on_wrong_version_output() { + use std::os::unix::fs::PermissionsExt; + let tmp_dir = tempfile::TempDir::new().unwrap(); + let script = tmp_dir.path().join("wrongver.sh"); + std::fs::write(&script, b"#!/bin/sh\necho v99.0.0\nexit 0\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let result = probe_node(&script, "v24.18.0", std::time::Duration::from_secs(3)); + assert!( + !result, + "probe_node must return false when stdout version does not match expected" + ); +} + +/// Windows-shaped seam: non-zero exit via .bat file. +/// +/// Drives the same probe_node path on Windows (terminate_process / taskkill /T /F). +/// This test FAILS if probe_node ignores exit_status.success() on Windows. +#[cfg(windows)] +#[test] +fn test_probe_node_windows_returns_false_on_nonzero_exit() { + let tmp_dir = tempfile::TempDir::new().unwrap(); + let bat = tmp_dir.path().join("fail.bat"); + // Prints the expected version but exits non-zero — must still fail. + std::fs::write(&bat, b"@echo off\r\necho v24.18.0\r\nexit /b 1\r\n").unwrap(); + + let result = probe_node(&bat, "v24.18.0", std::time::Duration::from_secs(3)); + assert!( + !result, + "probe_node must return false when the .bat exits non-zero (Windows)" + ); +} + +/// Windows-shaped seam: wrong version output via .bat file. +/// +/// This test FAILS if probe_node skips the version comparison on Windows. +#[cfg(windows)] +#[test] +fn test_probe_node_windows_returns_false_on_wrong_version_output() { + let tmp_dir = tempfile::TempDir::new().unwrap(); + let bat = tmp_dir.path().join("wrongver.bat"); + std::fs::write(&bat, b"@echo off\r\necho v99.0.0\r\nexit /b 0\r\n").unwrap(); + + let result = probe_node(&bat, "v24.18.0", std::time::Duration::from_secs(3)); + assert!( + !result, + "probe_node must return false when stdout version does not match (Windows)" + ); +} + +/// Returns false when the node binary path does not exist (fast path, no spawn). +#[test] +fn test_managed_node_runtime_ready_returns_false_when_binary_absent() { + let Some(node) = crate::managed_agents::buzz_managed_node_bin_path() else { + assert!( + !managed_node_runtime_ready(), + "managed_node_runtime_ready must return false when no path resolves" + ); + return; + }; + if node.is_file() { + return; + } + assert!( + !managed_node_runtime_ready(), + "managed_node_runtime_ready must return false when the binary file does not exist" + ); +} diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 7ce03b140b..54792f4b38 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -806,7 +806,7 @@ fn apply_model_provider_prompt_update( /// /// Does NOT auto-restart the agent. Runtime config changes (system prompt, /// parallelism, commands, toolsets) take effect on the next agent spawn. -/// Name changes are synced to the relay immediately via a kind:0 re-publish. +/// Name and avatar changes are synced to the relay immediately via a kind:0 re-publish. #[tauri::command] pub async fn update_managed_agent( input: UpdateManagedAgentRequest, @@ -841,6 +841,17 @@ pub async fn update_managed_agent( name_changed = true; } } + let mut avatar_changed = false; + if let Some(avatar_update) = input.avatar_url { + let normalized = avatar_update.and_then(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }); + if normalized != record.avatar_url { + record.avatar_url = normalized; + avatar_changed = true; + } + } apply_model_provider_prompt_update( record, input.model, @@ -943,7 +954,7 @@ pub async fn update_managed_agent( // update that touched only runtime/local fields is a no-op publish. super::agents::retain_managed_agent_pending(&app, &state, record); - let sync_params = if name_changed { + let sync_params = if name_changed || avatar_changed { let agent_keys = Keys::parse(&record.private_key_nsec) .map_err(|e| format!("failed to parse agent keys: {e}"))?; // Re-publish the renamed profile to the agent's effective relay: @@ -978,7 +989,8 @@ pub async fn update_managed_agent( &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), )? }; - let rollback = name_changed.then(|| AgentUpdateRollback::new(previous_record, record)); + let rollback = (name_changed || avatar_changed) + .then(|| AgentUpdateRollback::new(previous_record, record)); (summary, sync_params, rollback) }; // lock dropped here @@ -1003,7 +1015,7 @@ pub async fn update_managed_agent( })?; rollback_failed_agent_update(&app, &state, &summary.pubkey, rollback)?; return Err(format!( - "Agent rename failed because its relay profile could not be updated. No changes were saved: {sync_error}" + "Agent profile update failed because the relay profile could not be updated. No changes were saved: {sync_error}" )); } } diff --git a/desktop/src-tauri/src/commands/engrams.rs b/desktop/src-tauri/src/commands/engrams.rs index 74de129492..0a7f469ff7 100644 --- a/desktop/src-tauri/src/commands/engrams.rs +++ b/desktop/src-tauri/src/commands/engrams.rs @@ -26,7 +26,7 @@ use serde::Serialize; use tauri::{AppHandle, State}; use buzz_core_pkg::engram::{self, extract_refs, select_head, validate_and_decrypt, Body}; -use buzz_core_pkg::kind::KIND_AGENT_ENGRAM; +use buzz_core_pkg::kind::{KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE}; use crate::commands::identity_archive::{extract_oa_owner, fetch_kind0}; use crate::{app_state::AppState, managed_agents::load_managed_agents, relay::query_relay}; @@ -91,6 +91,33 @@ fn kind0_declares_viewer_owner(kind0: Option<&nostr::Event>, viewer_pubkey: &str } } +async fn hosted_profile_declares_viewer_owner( + state: &AppState, + agent_pubkey: &str, + viewer_pubkey: &str, +) -> Result { + let events = query_relay( + state, + &[serde_json::json!({ + "kinds": [KIND_AGENT_PROFILE], + "authors": [agent_pubkey], + "limit": 1, + })], + ) + .await?; + Ok(events.first().is_some_and(|event| { + serde_json::from_str::(&event.content) + .ok() + .and_then(|content| { + content + .get("owner_pubkey") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .is_some_and(|owner| owner.eq_ignore_ascii_case(viewer_pubkey)) + })) +} + /// `get_agent_memory` — owner-gated single-payload engram listing. /// /// Returns the full decrypted set for the (agent, owner) pair where @@ -147,15 +174,18 @@ pub async fn get_agent_memory( let is_declared_owner = if is_managed { false // already authorized; skip the relay roundtrip } else { - // Verify the agent's live `kind:0` declares the viewer as owner. + // Prefer the verified NIP-OA declaration, then accept the hosted + // agent's own signed directory profile. The engram remains encrypted + // to the viewer and p-gated by the relay; this only avoids rejecting a + // legitimate owner before that real boundary is exercised. let kind0 = fetch_kind0(&state, &agent_pubkey).await?; kind0_declares_viewer_owner(kind0.as_ref(), &viewer_pubkey) + || hosted_profile_declares_viewer_owner(&state, &agent_pubkey, &viewer_pubkey).await? }; if !is_managed && !is_declared_owner { return Err(format!( - "not the owner of agent {agent_pubkey} (no managed-agent record \ - and no verified NIP-OA owner declaration)" + "not the owner of agent {agent_pubkey} (no local record or signed owner declaration)" )); } diff --git a/desktop/src-tauri/src/commands/export_util.rs b/desktop/src-tauri/src/commands/export_util.rs index ded14679c1..e12cbd19e1 100644 --- a/desktop/src-tauri/src/commands/export_util.rs +++ b/desktop/src-tauri/src/commands/export_util.rs @@ -35,8 +35,8 @@ pub async fn pick_save_path( /// user cancelled the dialog. /// /// NOT for secrets: the write is plain `std::fs::write` (no atomic commit, no -/// 0o600). Secret exports go through `pick_save_path` + -/// `key_backup::write_backup_file`. +/// 0o600). Secret exports go through `pick_save_path` and a dedicated +/// secret-file writer such as `key_backup::write_portable_backup_file`. pub async fn save_bytes_with_dialog( app: &AppHandle, suggested_filename: &str, diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 33ecf3cfca..bddf2e725a 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -297,9 +297,10 @@ pub async fn verify_ncryptsec_backup( /// Save a portable copy of an `ncryptsec1…` backup to a user-chosen path. /// /// The input must parse as a structurally valid NIP-49 payload. The dialog is -/// selection-only; the write uses secret-file semantics (atomic + 0o600). -/// Never mutates canonical app state. Returns the chosen path, or `None` when -/// the user cancelled. +/// selection-only; the write uses the exact save-panel-authorized path with +/// owner-only permissions, sync, and reread verification. Existing files are +/// preserved rather than truncated. Never mutates canonical app state. Returns +/// the chosen path, or `None` when the user cancelled. #[tauri::command] pub async fn save_ncryptsec_copy( ncryptsec: String, @@ -324,7 +325,7 @@ pub async fn save_ncryptsec_copy( let dest_for_write = dest.clone(); tokio::task::spawn_blocking(move || { - crate::key_backup::write_backup_file(&dest_for_write, &normalized) + crate::key_backup::write_portable_backup_file(&dest_for_write, &normalized) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))??; diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index b7c37bec3d..638dd640d3 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -90,7 +90,7 @@ pub async fn get_feed( } // Needs-action: workflow approval-request events sent to me. let mut approval_filter = serde_json::json!({ - "kinds": [46010, 46011, 46012], + "kinds": [46010], "#p": [my_pubkey], "limit": 20, }); diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index fc874a0150..36435a617d 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -472,13 +472,13 @@ fn parse_relay_event(text: &str, sub_id: &str) -> Option { #[derive(Debug, PartialEq, Eq)] enum PairingRelay { Configured(String), - LegacyPath, MainRelay, } /// Prefer the relay-advertised dedicated pairing URL. The legacy `/pair` -/// convention remains as a compatibility fallback for NIP-43 relays that do -/// not advertise the extension yet. +/// convention is not inferred from NIP-43: membership support does not prove a +/// `/pair` sidecar exists, and modern Buzz relays carry pairing events on their +/// main WebSocket when no dedicated URL is advertised. async fn probe_pairing_relay(relay_url: &str) -> PairingRelay { let http_url = if let Some(rest) = relay_url.strip_prefix("wss://") { format!("https://{rest}") @@ -517,13 +517,6 @@ fn resolve_pairing_relay_url( ) -> Result { match pairing_relay { PairingRelay::Configured(url) => Ok(url), - PairingRelay::LegacyPath => { - let mut url = - url::Url::parse(main_relay_url).map_err(|e| format!("invalid relay URL: {e}"))?; - let path = url.path().trim_end_matches('/').to_string(); - url.set_path(&format!("{path}/pair")); - Ok(url.to_string()) - } PairingRelay::MainRelay => Ok(main_relay_url.to_string()), } } @@ -540,15 +533,7 @@ fn pairing_relay_from_nip11(json: &serde_json::Value) -> PairingRelay { } } - if json - .get("supported_nips") - .and_then(|value| value.as_array()) - .is_some_and(|nips| nips.iter().any(|nip| nip.as_u64() == Some(43))) - { - PairingRelay::LegacyPath - } else { - PairingRelay::MainRelay - } + PairingRelay::MainRelay } fn parse_auth_challenge(text: &str) -> Option { @@ -665,7 +650,7 @@ mod pairing_relay_tests { } #[test] - fn configured_pairing_relay_takes_precedence_over_legacy_path() { + fn configured_pairing_relay_takes_precedence_over_membership_support() { let document = serde_json::json!({ "pairing_relay_url": "wss://pairing.buzz.xyz", "supported_nips": [43] @@ -678,16 +663,13 @@ mod pairing_relay_tests { } #[test] - fn invalid_pairing_relay_url_falls_back_to_legacy_path() { + fn invalid_pairing_relay_url_uses_main_relay() { let document = serde_json::json!({ "pairing_relay_url": "https://pairing.buzz.xyz", "supported_nips": [43] }); - assert_eq!( - pairing_relay_from_nip11(&document), - PairingRelay::LegacyPath - ); + assert_eq!(pairing_relay_from_nip11(&document), PairingRelay::MainRelay); } #[test] @@ -709,14 +691,16 @@ mod pairing_relay_tests { } #[test] - fn legacy_pairing_relay_appends_pair_path() { + fn nip43_without_pairing_url_uses_main_relay() { let resolved = resolve_pairing_relay_url( "wss://flint.communities.buzz.xyz/community", - PairingRelay::LegacyPath, + pairing_relay_from_nip11(&serde_json::json!({ + "supported_nips": [1, 11, 43] + })), ) - .expect("resolve legacy pairing relay"); + .expect("resolve main pairing relay"); - assert_eq!(resolved, "wss://flint.communities.buzz.xyz/community/pair"); + assert_eq!(resolved, "wss://flint.communities.buzz.xyz/community"); } #[test] diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 9b60732822..7fe1d32389 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -11,6 +11,7 @@ use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST}; use nostr::{EventBuilder, EventId, Kind, Tag}; +use sha2::{Digest, Sha256}; use uuid::Uuid; // ── Constants ──────────────────────────────────────────────────────────────── @@ -810,13 +811,15 @@ pub fn build_workflow_trigger(workflow_id: &str) -> Result /// Kind 46030 — grant an approval token (with optional note). pub fn build_approval_grant(token: &str, note: Option<&str>) -> Result { - let tags = vec![tag(vec!["t", token])?]; + let token_hash = hex::encode(Sha256::digest(token.as_bytes())); + let tags = vec![tag(vec!["d", &token_hash])?]; Ok(EventBuilder::new(Kind::Custom(46030), note.unwrap_or("")).tags(tags)) } /// Kind 46031 — deny an approval token (with optional note). pub fn build_approval_deny(token: &str, note: Option<&str>) -> Result { - let tags = vec![tag(vec!["t", token])?]; + let token_hash = hex::encode(Sha256::digest(token.as_bytes())); + let tags = vec![tag(vec!["d", &token_hash])?]; Ok(EventBuilder::new(Kind::Custom(46031), note.unwrap_or("")).tags(tags)) } @@ -826,6 +829,25 @@ pub fn build_approval_deny(token: &str, note: Option<&str>) -> Result> = event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + assert_eq!(tags, vec![vec!["d".to_string(), expected_hash.clone()]]); + } + } + #[test] fn channel_builders_reject_hash_only_names() { let channel_id = Uuid::new_v4(); diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs index f97bf95a67..e8fcc8abe4 100644 --- a/desktop/src-tauri/src/key_backup.rs +++ b/desktop/src-tauri/src/key_backup.rs @@ -133,9 +133,14 @@ pub fn backup_file_path(data_dir: &std::path::Path) -> std::path::PathBuf { data_dir.join(BACKUP_FILE_NAME) } -/// Atomically write `ncryptsec` to `path` with owner-only permissions, then -/// reread and byte-compare. Same crash-safety pattern as +/// Atomically write the app-managed `ncryptsec` backup with owner-only +/// permissions, then reread and byte-compare. Same crash-safety pattern as /// `app_state::save_key_file`. +/// +/// Portable exports selected through a native save panel must use +/// [`write_portable_backup_file`] instead: sandboxed macOS grants access to the +/// selected path, but not to the sibling temporary file this writer needs. +#[allow(dead_code)] // Retained for durable app-managed backups; portable exports must not use it. pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> { use atomic_write_file::AtomicWriteFile; use std::io::Write; @@ -155,6 +160,56 @@ pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), file.commit() .map_err(|e| format!("commit backup file: {e}"))?; + verify_backup_file(path, ncryptsec) +} + +/// Write a user-selected portable backup without creating a sibling file. +/// +/// Native macOS save panels authorize the exact selected path in protected +/// folders such as Downloads, not an atomic writer's hidden sibling. Opening +/// with `create_new` uses only that authorized path and also guarantees an +/// existing backup is never truncated: users must choose a new filename when +/// the destination already exists. After writing, the file is synced and its +/// persisted bytes are reread before success is reported. +pub fn write_portable_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> { + use std::io::Write; + + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + let mut file = options.open(path).map_err(|error| { + if error.kind() == std::io::ErrorKind::AlreadyExists { + "backup file already exists; choose a new filename so the existing backup stays safe" + .to_string() + } else { + format!("create portable backup file: {error}") + } + })?; + + let write_result = file + .write_all(ncryptsec.as_bytes()) + .map_err(|e| format!("write portable backup file: {e}")) + .and_then(|()| { + file.sync_all() + .map_err(|e| format!("sync portable backup file: {e}")) + }); + drop(file); + + let result = write_result.and_then(|()| verify_backup_file(path, ncryptsec)); + if result.is_err() { + // This function created the destination exclusively, so cleanup cannot + // clobber a backup that existed before the save attempt. + let _ = std::fs::remove_file(path); + } + result +} + +fn verify_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> { // Reread and byte-compare: only report success for bytes that are // actually on disk. let on_disk = std::fs::read_to_string(path).map_err(|e| format!("reread backup file: {e}"))?; diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs index b9713201e1..35b486f78d 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -160,6 +160,45 @@ fn write_backup_file_overwrites_atomically() { assert_eq!(entries, vec![std::ffi::OsString::from(BACKUP_FILE_NAME)]); } +#[test] +fn write_portable_backup_file_persists_0600_without_a_sibling() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("portable.ncryptsec"); + write_portable_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + + assert_eq!(std::fs::read_to_string(&path).unwrap(), SPEC_NCRYPTSEC); + let entries: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!( + entries, + vec![std::ffi::OsString::from("portable.ncryptsec")] + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "portable backup must be owner-only"); + } +} + +#[test] +fn write_portable_backup_file_preserves_an_existing_backup() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("portable.ncryptsec"); + std::fs::write(&path, "ncryptsec1existing").unwrap(); + + let error = write_portable_backup_file(&path, SPEC_NCRYPTSEC).unwrap_err(); + + assert!(error.contains("already exists"), "{error}"); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "ncryptsec1existing" + ); +} + #[test] fn delete_backup_file_is_idempotent() { let dir = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index ba1db8e36f..dd99808a74 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -9,10 +9,10 @@ use crate::managed_agents::{ AcpAvailabilityStatus, AcpRuntimeCatalogEntry, AuthStatus, CommandAvailabilityInfo, HarnessSource, }; - mod presets; mod runtime_metadata; - +#[macro_use] +mod windows_install; use presets::{preset_catalog_entry, PRESET_HARNESSES}; pub(crate) use presets::{preset_harness_definitions, preset_harness_ids}; pub(crate) use runtime_metadata::KnownAcpRuntime; @@ -85,7 +85,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], // Goose's stable release currently publishes only the Unix installer; // its official Windows instructions intentionally point at this main-branch script. - cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex\""], + cli_install_commands_windows: &[windows_install_command!("goose", "https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1", "$env:CONFIGURE='false'; ")], adapter_install_commands: &[], cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", adapter_install_instructions_url: "", @@ -117,7 +117,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ mcp_hooks: false, underlying_cli: Some("claude"), cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], - cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://claude.ai/install.ps1 | iex\""], + cli_install_commands_windows: &[windows_install_command!("claude", "https://claude.ai/install.ps1")], adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", @@ -149,7 +149,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ mcp_hooks: false, underlying_cli: Some("codex"), cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], - cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://chatgpt.com/codex/install.ps1 | iex\""], + cli_install_commands_windows: &[windows_install_command!("codex", "https://chatgpt.com/codex/install.ps1")], adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], cli_install_instructions_url: "https://developers.openai.com/codex/cli/", adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", diff --git a/desktop/src-tauri/src/managed_agents/discovery/windows_install.rs b/desktop/src-tauri/src/managed_agents/discovery/windows_install.rs new file mode 100644 index 0000000000..09e27a62be --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/windows_install.rs @@ -0,0 +1,225 @@ +//! Defender-safe construction of the Windows PowerShell CLI install commands. +//! +//! # Why the shape matters +//! +//! Windows Defender's ML classifier flags the bare `irm | iex` command +//! line as `Trojan:Win32/Commando.A!ml` — piping a downloaded string straight +//! into `Invoke-Expression` is a textbook dropper signature, so the *command +//! line itself* is scored, independent of what the URL actually serves. The +//! spawn is denied before PowerShell runs, surfacing as +//! `failed to spawn shell: Access is denied. (os error 5)`, and the block is +//! sticky: Defender's "Allow" button does not clear it. +//! +//! [`windows_install_command!`] emits the two-step form instead — download the +//! vendor script to a file, then execute the file — which does not match that +//! signature. All three runtimes use it, not only the one observed failing: +//! Goose and Claude escaped by scoring under the classifier threshold, which is +//! luck rather than design, and the threshold is not ours to depend on. +//! +//! # Why one macro instead of three literals +//! +//! The catalog needs `&'static str`, so the commands must be built at compile +//! time from literals. Emitting them from a single macro means the security +//! shape is defined once and cannot drift between runtimes as URLs change — +//! a per-runtime literal would let one entry silently regress to `iex`. +//! +//! # Exit-code fidelity +//! +//! [#2892](https://github.com/block/buzz/pull/2892) established that an install +//! step must not report success when the download failed. Two pieces preserve +//! that here, and both are load-bearing: +//! +//! - `$ErrorActionPreference='Stop'` makes a failed `Invoke-RestMethod` +//! terminate the whole command. Without it a failed download falls through to +//! `& $installer` on a path that does not exist, and PowerShell exits **0** — +//! the exact masking #2892 removed, in a new dress. `Stop` also prevents +//! executing a *stale* installer left in `$env:TEMP` by an earlier run. +//! - `exit $LASTEXITCODE` propagates the vendor script's own exit code. Without +//! it PowerShell reports its own status and a vendor failure of `3` flattens +//! to `1`, losing the distinction the retry logic reads. +//! +//! Verified against `pwsh` over a local HTTP server: vendor exit 3 surfaces as +//! 3, vendor exit 0 as 0, a 404 and an unresolvable host as non-zero, and a +//! planted stale installer is never executed. The old `irm | iex` shape +//! produces identical codes for all four, so this is not a behavior change. +//! +//! # Quoting contract +//! +//! The emitted body is wrapped in one double-quote pair, which +//! `install_powershell_command` strips before handing the body to PowerShell. +//! The body therefore uses **only single quotes** internally; a double quote +//! would terminate that pair early and truncate the command. + +/// Build the Windows CLI install command for one runtime. +/// +/// `slug` names the downloaded script (`buzz-install-.ps1`) so concurrent +/// installs of different runtimes cannot overwrite each other's file. The +/// optional third argument carries a runtime's env prefix (Goose's +/// `$env:CONFIGURE='false'; `) and must end with `; `. +/// +/// See the module docs for why each fragment is present. +macro_rules! windows_install_command { + ($slug:literal, $url:literal) => { + windows_install_command!($slug, $url, "") + }; + ($slug:literal, $url:literal, $env_prefix:literal) => { + concat!( + "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"", + $env_prefix, + "$ErrorActionPreference='Stop'; ", + "$installer=Join-Path $env:TEMP 'buzz-install-", + $slug, + ".ps1'; ", + "Invoke-RestMethod ", + $url, + " -OutFile $installer; ", + "& $installer; ", + "exit $LASTEXITCODE\"", + ) + }; +} + +#[cfg(test)] +mod tests { + use crate::managed_agents::known_acp_runtime_exact; + + /// Every runtime that ships a Windows install command. `cli_install_commands_windows` + /// is read directly rather than through `cli_install_commands_for_os()` so these + /// assertions cover the Windows strings while running on the Linux CI host. + fn windows_install_commands() -> Vec<(&'static str, &'static str)> { + ["goose", "claude", "codex"] + .into_iter() + .flat_map(|id| { + known_acp_runtime_exact(id) + .expect("runtime must exist in the catalog") + .cli_install_commands_windows + .iter() + .map(move |command| (id, *command)) + }) + .collect() + } + + /// The whole point of the change: no runtime may carry the flagged + /// download-and-execute-in-one-line signature. + #[test] + fn test_no_windows_install_command_pipes_a_download_into_iex() { + for (id, command) in windows_install_commands() { + assert!( + !command.contains("| iex"), + "{id}: `irm | iex` is the shape Defender flags as Trojan:Win32/Commando.A!ml; \ + download to a file and execute the file instead. Got: {command}" + ); + assert!( + !command.contains("Invoke-Expression"), + "{id}: Invoke-Expression on downloaded content carries the same signature. \ + Got: {command}" + ); + } + } + + /// All three runtimes must be hardened, not just the one observed failing. + /// Goose and Claude escaped only by scoring under the classifier threshold. + #[test] + fn test_every_windows_install_command_downloads_to_a_file_then_executes_it() { + let commands = windows_install_commands(); + assert_eq!( + commands.len(), + 3, + "expected exactly one Windows install command for each of goose, claude, codex" + ); + for (id, command) in commands { + assert!( + command.contains("-OutFile $installer"), + "{id}: must download the vendor script to a file. Got: {command}" + ); + assert!( + command.contains("& $installer"), + "{id}: must execute the downloaded file. Got: {command}" + ); + assert!( + command.contains(&format!("buzz-install-{id}.ps1")), + "{id}: script name must be runtime-specific so concurrent installs of \ + different runtimes cannot overwrite each other. Got: {command}" + ); + } + } + + /// Guards the #2892 regression: without `Stop`, a failed download falls + /// through to a missing file and PowerShell exits 0, reporting a failed + /// install as a success. Without `exit $LASTEXITCODE`, the vendor's own + /// exit code is replaced by PowerShell's. + #[test] + fn test_every_windows_install_command_preserves_failure_exit_codes() { + for (id, command) in windows_install_commands() { + assert!( + command.contains("$ErrorActionPreference='Stop'"), + "{id}: a failed download must abort instead of running a missing or stale \ + installer and exiting 0 (see #2892). Got: {command}" + ); + assert!( + command.contains("exit $LASTEXITCODE"), + "{id}: the vendor script's exit code must propagate. Got: {command}" + ); + } + } + + /// `install_powershell_command` strips exactly one outer double-quote pair. + /// An inner double quote would close that pair early and truncate the body. + #[test] + fn test_every_windows_install_command_quotes_the_body_exactly_once() { + for (id, command) in windows_install_commands() { + let body = command + .split_once(" -Command ") + .map(|(_, body)| body) + .unwrap_or_else(|| panic!("{id}: command must pass a -Command body: {command}")); + assert!( + body.starts_with('"') && body.ends_with('"'), + "{id}: body must be wrapped in one double-quote pair. Got: {body}" + ); + assert_eq!( + body.matches('"').count(), + 2, + "{id}: body must contain no inner double quotes — one would terminate the \ + outer pair early and truncate the command. Got: {body}" + ); + } + } + + /// Goose's installer reads `CONFIGURE` to stay non-interactive; losing the + /// prefix hangs the install waiting on input that never comes. + #[test] + fn test_goose_windows_install_command_keeps_its_env_prefix() { + let goose = known_acp_runtime_exact("goose").unwrap(); + let command = goose.cli_install_commands_windows[0]; + assert!( + command.contains("$env:CONFIGURE='false'"), + "goose must stay non-interactive. Got: {command}" + ); + assert!( + command.find("$env:CONFIGURE='false'").unwrap() + < command.find("Invoke-RestMethod").unwrap(), + "the env prefix must be set before the installer runs. Got: {command}" + ); + } + + /// The vendor URLs are the payload; pin them so a refactor of the shared + /// shape cannot silently retarget a download. + #[test] + fn test_windows_install_commands_target_the_official_vendor_urls() { + for (id, expected) in [ + ( + "goose", + "https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1", + ), + ("claude", "https://claude.ai/install.ps1"), + ("codex", "https://chatgpt.com/codex/install.ps1"), + ] { + let runtime = known_acp_runtime_exact(id).unwrap(); + let command = runtime.cli_install_commands_windows[0]; + assert!( + command.contains(&format!("Invoke-RestMethod {expected} -OutFile")), + "{id}: must download from {expected}. Got: {command}" + ); + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index d8eac78394..25ea134a55 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -208,6 +208,16 @@ pub struct RelayAgentInfo { pub audience: Option, pub owner_pubkey: Option, pub access_tier: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub models: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RelayAgentModelInfo { + pub id: String, + pub name: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ManagedAgentRecord { diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index e28b0bd461..f41d164148 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -203,6 +203,9 @@ pub struct UpdateManagedAgentRequest { /// Absent = don't touch. Present = rename the agent. #[serde(default)] pub name: Option, + /// Absent = don't touch. null/blank = remove the custom avatar. + #[serde(default, deserialize_with = "crate::util::double_option")] + pub avatar_url: Option>, /// Absent = don't touch. null = clear to agent default. "id" = set. #[serde(default)] pub model: Option>, @@ -467,4 +470,25 @@ mod tests { .expect("a create payload without provenance should deserialize"); assert_eq!(request.catalog_source, None); } + + #[test] + fn update_request_preserves_avatar_patch_tristate() { + let absent: UpdateManagedAgentRequest = + serde_json::from_str(r#"{ "pubkey": "agent" }"#).expect("avatar may be omitted"); + assert_eq!(absent.avatar_url, None); + + let cleared: UpdateManagedAgentRequest = + serde_json::from_str(r#"{ "pubkey": "agent", "avatarUrl": null }"#) + .expect("avatar may be cleared"); + assert_eq!(cleared.avatar_url, Some(None)); + + let changed: UpdateManagedAgentRequest = serde_json::from_str( + r#"{ "pubkey": "agent", "avatarUrl": "https://example.com/new.png" }"#, + ) + .expect("avatar may be changed"); + assert_eq!( + changed.avatar_url, + Some(Some("https://example.com/new.png".to_string())) + ); + } } diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index b0ce894931..e933eb9edb 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -4,6 +4,7 @@ import type { SearchHit } from "@/shared/api/types"; export type AppView = | "home" + | "alerts" | "channel" | "messages" | "agents" @@ -110,6 +111,13 @@ export function deriveShellRoute(pathname: string): { selectedChannelId: string | null; selectedView: AppView; } { + if (pathname === "/alerts") { + return { + selectedChannelId: null, + selectedView: "alerts", + }; + } + if (pathname.startsWith("/channels/")) { const [, , rawChannelId] = pathname.split("/"); return { diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 4eb0a42bbe..88adaa0d0b 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -65,7 +65,6 @@ import { } from "@/features/settings/ui/SettingsPanels"; import { HuddleProvider } from "@/features/huddle"; import { AppHuddleBar } from "@/app/AppHuddleBar"; -import { useDueReminderBadgeCount } from "@/features/reminders/hooks"; import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider"; import { useReminderNotifications } from "@/features/reminders/useReminderNotifications"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; @@ -125,6 +124,7 @@ export function AppShell() { const queryClient = useQueryClient(); useManagedAgentRuntimeReconciliation(communitiesHook.communities); // sync storage snapshot const { + goAlerts, goAgents, goChannel, goHome, @@ -425,10 +425,6 @@ export function AppShell() { channels, ); - const dueReminderBadge = useDueReminderBadgeCount( - identityQuery.data?.pubkey, - notificationSettings.settings.homeBadgeEnabled, - ); const isNotifiedForThread = React.useCallback( (rootId: string) => !mutedRootIds.has(rootId) && @@ -826,7 +822,7 @@ export function AppShell() { fallbackDisplayName={ identityQuery.data?.displayName } - homeBadgeCount={homeBadgeCount + dueReminderBadge} + homeBadgeCount={homeBadgeCount} addCommunityPrefill={addCommunityDialog.prefill} isAddCommunityOpen={addCommunityDialog.open} relayConnectionCard={relayConnectionCard} @@ -875,6 +871,7 @@ export function AppShell() { await goChannel(directMessage.id); }} onSelectAgents={() => void goAgents()} + onSelectAlerts={() => void goAlerts()} onSelectChannel={(channelId) => void goChannel(channelId) } diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index d19ac03120..8cefb4c5fd 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -57,6 +57,17 @@ export function useAppNavigation() { [commitNavigation], ); + const goAlerts = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/alerts", + }, + behavior, + ), + [commitNavigation], + ); + const goAgents = React.useCallback( (behavior?: NavigationBehavior) => commitNavigation( @@ -304,6 +315,7 @@ export function useAppNavigation() { ); return { + goAlerts, closeForumPost, closeSettings, closeWorkflowDetail, diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index 2bc2c8ddb6..18d61058ea 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -10,6 +10,8 @@ import { Route as settingsRouteImport } from "./routes/settings"; import { Route as remindersRouteImport } from "./routes/reminders"; import { Route as pulseRouteImport } from "./routes/pulse"; import { Route as projectsRouteImport } from "./routes/projects"; +import { Route as draftsRouteImport } from "./routes/drafts"; +import { Route as alertsRouteImport } from "./routes/alerts"; import { Route as agentsRouteImport } from "./routes/agents"; import { Route as indexRouteImport } from "./routes/index"; import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId"; @@ -43,6 +45,16 @@ const projectsRoute = projectsRouteImport.update({ path: "/projects", getParentRoute: () => rootRouteImport, } as any); +const draftsRoute = draftsRouteImport.update({ + id: "/drafts", + path: "/drafts", + getParentRoute: () => rootRouteImport, +} as any); +const alertsRoute = alertsRouteImport.update({ + id: "/alerts", + path: "/alerts", + getParentRoute: () => rootRouteImport, +} as any); const agentsRoute = agentsRouteImport.update({ id: "/agents", path: "/agents", @@ -83,6 +95,8 @@ const channelsDotchannelIdDotpostsDotpostIdRoute = export interface FileRoutesByFullPath { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/alerts": typeof alertsRoute; + "/drafts": typeof draftsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -97,6 +111,8 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/alerts": typeof alertsRoute; + "/drafts": typeof draftsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -112,6 +128,8 @@ export interface FileRoutesById { __root__: typeof rootRouteImport; "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/alerts": typeof alertsRoute; + "/drafts": typeof draftsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -128,6 +146,8 @@ export interface FileRouteTypes { fullPaths: | "/" | "/agents" + | "/alerts" + | "/drafts" | "/projects" | "/pulse" | "/reminders" @@ -142,6 +162,8 @@ export interface FileRouteTypes { to: | "/" | "/agents" + | "/alerts" + | "/drafts" | "/projects" | "/pulse" | "/reminders" @@ -156,6 +178,8 @@ export interface FileRouteTypes { | "__root__" | "/" | "/agents" + | "/alerts" + | "/drafts" | "/projects" | "/pulse" | "/reminders" @@ -171,6 +195,8 @@ export interface FileRouteTypes { export interface RootRouteChildren { indexRoute: typeof indexRoute; agentsRoute: typeof agentsRoute; + alertsRoute: typeof alertsRoute; + draftsRoute: typeof draftsRoute; projectsRoute: typeof projectsRoute; pulseRoute: typeof pulseRoute; remindersRoute: typeof remindersRoute; @@ -220,6 +246,20 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof projectsRouteImport; parentRoute: typeof rootRouteImport; }; + "/drafts": { + id: "/drafts"; + path: "/drafts"; + fullPath: "/drafts"; + preLoaderRoute: typeof draftsRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/alerts": { + id: "/alerts"; + path: "/alerts"; + fullPath: "/alerts"; + preLoaderRoute: typeof alertsRouteImport; + parentRoute: typeof rootRouteImport; + }; "/agents": { id: "/agents"; path: "/agents"; @@ -275,6 +315,8 @@ declare module "@tanstack/react-router" { const rootRouteChildren: RootRouteChildren = { indexRoute: indexRoute, agentsRoute: agentsRoute, + alertsRoute: alertsRoute, + draftsRoute: draftsRoute, projectsRoute: projectsRoute, pulseRoute: pulseRoute, remindersRoute: remindersRoute, diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index f5c6938e11..9c5478b33e 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -2,7 +2,9 @@ import { index, rootRoute, route } from "@tanstack/virtual-file-routes"; export const routes = rootRoute("root.tsx", [ index("index.tsx"), + route("/alerts", "alerts.tsx"), route("/agents", "agents.tsx"), + route("/drafts", "drafts.tsx"), route("/pulse", "pulse.tsx"), route("/reminders", "reminders.tsx"), route("/settings", "settings.tsx"), diff --git a/desktop/src/app/routes/alerts.tsx b/desktop/src/app/routes/alerts.tsx new file mode 100644 index 0000000000..6aab54c1e4 --- /dev/null +++ b/desktop/src/app/routes/alerts.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { HomeRouteComponent } from "@/app/routes/index"; + +export const Route = createFileRoute("/alerts")({ + component: AlertsRouteComponent, +}); + +function AlertsRouteComponent() { + return ; +} diff --git a/desktop/src/app/routes/drafts.tsx b/desktop/src/app/routes/drafts.tsx new file mode 100644 index 0000000000..ea194f84dc --- /dev/null +++ b/desktop/src/app/routes/drafts.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { HomeRouteComponent } from "@/app/routes/index"; + +export const Route = createFileRoute("/drafts")({ + component: DraftsRouteComponent, +}); + +function DraftsRouteComponent() { + return ; +} diff --git a/desktop/src/app/routes/index.tsx b/desktop/src/app/routes/index.tsx index 82deb4ac73..f0ddb81215 100644 --- a/desktop/src/app/routes/index.tsx +++ b/desktop/src/app/routes/index.tsx @@ -4,6 +4,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; import { HomeScreen } from "@/features/home/ui/HomeScreen"; +import type { InboxFilter } from "@/features/home/lib/inbox"; import { consumePendingWelcomeChannel, WELCOME_CHANNEL_READY_EVENT, @@ -43,7 +44,11 @@ export const Route = createFileRoute("/")({ component: HomeRouteComponent, }); -function HomeRouteComponent() { +export function HomeRouteComponent({ + initialFilter = "all", +}: { + initialFilter?: InboxFilter; +} = {}) { const { goChannel } = useAppNavigation(); const channelsQuery = useChannelsQuery(); const identityQuery = useIdentityQuery(); @@ -94,6 +99,7 @@ function HomeRouteComponent() { { void goChannel(channelId, { messageId, threadRootId }); }} diff --git a/desktop/src/app/routes/reminders.tsx b/desktop/src/app/routes/reminders.tsx index 83f01e45e1..2b616303ce 100644 --- a/desktop/src/app/routes/reminders.tsx +++ b/desktop/src/app/routes/reminders.tsx @@ -1,11 +1,11 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; + +import { HomeRouteComponent } from "@/app/routes/index"; -// Reminders is now a filter option inside the inbox dropdown, selected via -// local state rather than the URL. This redirect preserves existing history -// entries and bookmarks pointing at `/reminders` so they land in the inbox -// instead of dead-ending; the user re-selects Reminders from the filter. export const Route = createFileRoute("/reminders")({ - beforeLoad: () => { - throw redirect({ to: "/" }); - }, + component: RemindersRouteComponent, }); + +function RemindersRouteComponent() { + return ; +} diff --git a/desktop/src/app/useAppShellDesktopNotifications.ts b/desktop/src/app/useAppShellDesktopNotifications.ts index 2266862cac..d1d2dc65a4 100644 --- a/desktop/src/app/useAppShellDesktopNotifications.ts +++ b/desktop/src/app/useAppShellDesktopNotifications.ts @@ -22,6 +22,7 @@ import { resolveSlotSound, } from "@/features/notifications/lib/sound"; import type { Channel, RelayEvent } from "@/shared/api/types"; +import { KIND_APPROVAL_REQUEST } from "@/shared/constants/kinds"; export function useAppShellDesktopNotifications({ channels, @@ -135,6 +136,11 @@ export function useAppShellDesktopNotifications({ ) => { await revealDesktopAppWindow(); + if (target.kind === KIND_APPROVAL_REQUEST) { + await goHome(); + return; + } + if (!target.channelId) { void goHome(); return; diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index d9222c7032..73059e2e75 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -4,6 +4,11 @@ Scope: `desktop/src/features/agents/` (config surfaces, shared config renderer, and the agent config core). Read this before changing how harness / provider / model / effort configuration is modeled, rendered, persisted, or applied. +Cross-surface plan: `docs/agent-surface-map.md`. Read and update that map when +changing agent identity, avatar, model, access, channel membership, mentions, +Inbox/history presentation, routes, event shapes, or cache invalidation. A +configuration change is incomplete when only the `/agents` screen is updated. + Plan of record: `Buzz/Harness-Provider-Model.md` in Morgan's Obsidian vault (PR sequence, decisions log). PRs: #2140 (rename), #2148 (flag reduction), #2156 (honest model states), #2158 (Agent Config Core). @@ -26,6 +31,16 @@ with a TypeScript lookup table or an id comparison in a component. ## Rules +Hosted-agent identity/config is a separate capability-driven path. The hosted +runtime advertises its model catalog in its signed kind:10100 directory event; +the frontend must render those options and must not maintain a provider/model +table. Admin edits are stored in an admin-authored kind:30179 event keyed by the +hosted agent pubkey. `list_relay_agents` accepts only community owner/admin +authors (or the agent's declared owner), chooses the newest authorized head, +and every presentation surface treats that merged name/avatar as +authoritative over stale kind:0 metadata. A hosted model save also uses the +authenticated observer `switch_model` control for the agent's known channels. + 1. **No hardcoded harness-ID checks in render code.** `runtime.id === "claude"` belongs in `deriveAgentConfigFieldModel` (once, with a named reason), never in a component. Components ask the field model what exists @@ -80,7 +95,10 @@ with a TypeScript lookup table or an id comparison in a component. picker while discovery is in flight and after IPC resolves with no usable options (`modelDiscoverySuccessfulEmpty` / `isSuccessfulEmptyDiscovery`). A thrown or unavailable discovery keeps the control so #2246 failure UI can - render, and must not heal/clear persisted model or effort. Full disclosure + render, and must not heal/clear persisted model or effort. When that control + remains visible for an `acpNative` harness, its zero-value option is + **Runtime default** even when no baked/global fallback exists; optional + native selection must never collapse to only **Custom model**. Full disclosure still shows the control when Custom model is available. Required-model harnesses always keep the field. Gate: `defaults hides model when optional harness has empty discovery` (and the failed-discovery counterpart) in @@ -173,6 +191,10 @@ with a TypeScript lookup table or an id comparison in a component. - Rust: `runtime_metadata_env_vars` tests pin spawn-time key application. - Rust: persona sharing/retention tests pin relay+owner scoping, durable enqueue errors, relay rejection/unavailability, and accepted publication. +- `lib/agentSurfaceMapContract.test.mjs` — all declared desktop/web routes stay + inventoried, search and mentions share the canonical access policy, Inbox + overlays current hosted presentation, and both clients retain hosted-config + readers. ## Keep this file true diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 122c872e54..eae9f75682 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -415,7 +415,7 @@ export function useUpdateManagedAgentMutation() { } }, onSettled: async (_data, _error, variables) => { - // Backend republishes kind:0 on a name change (sync_managed_agent_profile), + // Backend republishes kind:0 on a profile change (sync_managed_agent_profile), // so the relay has fresh profile data — but the desktop's React Query cache // for ["user-profile", pubkey] has a 60s staleTime and will not refetch on // its own. Invalidate explicitly so the profile pane re-renders against diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd68..0d4bc7eb4a 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -5,8 +5,9 @@ import { coalesceAgentAutocompleteCandidates, getMentionableAgentPubkeys, getSharedChannelIds, - isAgentIdentityInManagedList, + isAgentIdentityInKnownDirectories, relayAgentIsSharedWithUser, + resolveAgentMentionDisplayName, shouldHideAgentFromMentions, } from "./agentAutocompleteEligibility.ts"; @@ -47,7 +48,18 @@ test("getSharedChannelIds: includes only active joined channels", () => { ); }); -test("relayAgentIsSharedWithUser: accepts shared anyone agents and rejects unshared ones", () => { +test("resolveAgentMentionDisplayName: current directory name replaces stale channel membership label", () => { + assert.equal( + resolveAgentMentionDisplayName({ + directoryName: "Sylar", + memberName: "Founder Chief of Staff", + profileDisplayName: "Older profile name", + }), + "Sylar", + ); +}); + +test("relayAgentIsSharedWithUser: accepts anyone agents before their first shared channel", () => { const sharedChannelIds = new Set(["general"]); assert.equal( @@ -63,8 +75,10 @@ test("relayAgentIsSharedWithUser: accepts shared anyone agents and rejects unsha respondTo: "owner-only", respondToAllowlist: [], channelIds: ["general"], + ownerPubkey: OWNER_PUBKEY, }, sharedChannelIds, + CURRENT_PUBKEY, ), false, ); @@ -73,6 +87,74 @@ test("relayAgentIsSharedWithUser: accepts shared anyone agents and rejects unsha { respondTo: "anyone", respondToAllowlist: [], channelIds: ["other"] }, sharedChannelIds, ), + true, + ); +}); + +test("relayAgentIsSharedWithUser: uses hosted visibility even when response policy is stale", () => { + assert.equal( + relayAgentIsSharedWithUser( + { + accessTier: "shared", + audience: "community", + respondTo: "owner-only", + respondToAllowlist: [], + channelIds: [], + ownerPubkey: OTHER_OWNER_PUBKEY, + }, + new Set(), + CURRENT_PUBKEY, + ), + true, + ); + + assert.equal( + relayAgentIsSharedWithUser( + { + accessTier: "personal", + audience: "owner", + respondTo: "anyone", + respondToAllowlist: [], + channelIds: ["general"], + ownerPubkey: OTHER_OWNER_PUBKEY, + }, + new Set(["general"]), + CURRENT_PUBKEY, + ), + false, + ); +}); + +test("relayAgentIsSharedWithUser: accepts owner-only hosted agents for their owner", () => { + const sharedChannelIds = new Set(); + + for (const respondTo of ["owner-only", null]) { + assert.equal( + relayAgentIsSharedWithUser( + { + respondTo, + respondToAllowlist: [], + channelIds: [], + ownerPubkey: CURRENT_PUBKEY.toUpperCase(), + }, + sharedChannelIds, + CURRENT_PUBKEY, + ), + true, + ); + } + + assert.equal( + relayAgentIsSharedWithUser( + { + respondTo: "owner-only", + respondToAllowlist: [], + channelIds: ["general"], + ownerPubkey: OTHER_OWNER_PUBKEY, + }, + new Set(["general"]), + CURRENT_PUBKEY, + ), false, ); }); @@ -106,7 +188,7 @@ test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user" ); }); -test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", () => { +test("getMentionableAgentPubkeys: keeps managed and community-wide relay agents", () => { const result = getMentionableAgentPubkeys({ managedAgentPubkeys: [PUB_A], currentPubkey: CURRENT_PUBKEY, @@ -133,30 +215,42 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", sharedChannelIds: new Set(["general"]), }); - assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C])); + assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C, PUB_D])); }); -test("isAgentIdentityInManagedList: keeps people and only current managed agent identities", () => { +test("isAgentIdentityInKnownDirectories: keeps people and known managed or relay agent identities", () => { const managedAgentPubkeys = new Set([PUB_A]); + const relayAgentPubkeys = new Set([PUB_B]); assert.equal( - isAgentIdentityInManagedList( + isAgentIdentityInKnownDirectories( { isAgent: false, pubkey: PUB_B }, managedAgentPubkeys, + relayAgentPubkeys, ), true, ); assert.equal( - isAgentIdentityInManagedList( + isAgentIdentityInKnownDirectories( { isAgent: true, pubkey: PUB_A.toUpperCase() }, managedAgentPubkeys, + relayAgentPubkeys, ), true, ); assert.equal( - isAgentIdentityInManagedList( + isAgentIdentityInKnownDirectories( { isAgent: true, pubkey: PUB_B }, managedAgentPubkeys, + relayAgentPubkeys, + ), + true, + ); + assert.equal( + isAgentIdentityInKnownDirectories( + { isAgent: true, pubkey: PUB_C }, + managedAgentPubkeys, + relayAgentPubkeys, ), false, ); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index e4afe7fea4..1f43246359 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -1,6 +1,26 @@ import type { Channel, RelayAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; +export function resolveAgentMentionDisplayName({ + directoryName, + memberName, + profileDisplayName, + profileHandle, +}: { + directoryName?: string | null; + memberName?: string | null; + profileDisplayName?: string | null; + profileHandle?: string | null; +}) { + return ( + directoryName?.trim() || + memberName?.trim() || + profileDisplayName?.trim() || + profileHandle?.trim() || + null + ); +} + export function getSharedChannelIds(channels: readonly Channel[] | undefined) { return new Set( (channels ?? []) @@ -10,24 +30,68 @@ export function getSharedChannelIds(channels: readonly Channel[] | undefined) { } export function relayAgentIsSharedWithUser( - agent: Pick, - sharedChannelIds: ReadonlySet, + agent: Pick< + RelayAgent, + | "accessTier" + | "audience" + | "channelIds" + | "ownerPubkey" + | "respondTo" + | "respondToAllowlist" + >, + _sharedChannelIds: ReadonlySet, currentPubkey?: string | null, ) { const normalizedCurrentPubkey = currentPubkey ? normalizePubkey(currentPubkey) : null; + const normalizedOwnerPubkey = agent.ownerPubkey + ? normalizePubkey(agent.ownerPubkey) + : null; + + const isPrivateAgent = + agent.audience === "owner" || + agent.accessTier === "personal" || + agent.accessTier === "admin"; + if (isPrivateAgent) { + return Boolean( + normalizedCurrentPubkey && + normalizedOwnerPubkey === normalizedCurrentPubkey, + ); + } + + // Access tier and audience are the authoritative visibility controls for + // the hosted directory. Community agents must be discoverable before their + // first channel invitation; older runtime records can still carry a stale + // owner-only response policy after an agent was promoted to shared. + if (agent.audience === "community" || agent.accessTier === "shared") { + return true; + } + + // The relay defaults a missing respond_to value to owner-only. Hosted + // personal/admin agents are still invocable by their owner even when they + // are not members of the channel being composed in. + if ( + normalizedCurrentPubkey && + normalizedOwnerPubkey === normalizedCurrentPubkey && + (agent.respondTo === null || agent.respondTo === "owner-only") + ) { + return true; + } + if (agent.respondTo === "allowlist" && normalizedCurrentPubkey) { return agent.respondToAllowlist .map((pubkey) => normalizePubkey(pubkey)) .includes(normalizedCurrentPubkey); } - return ( - agent.respondTo === "anyone" && - agent.channelIds.some((channelId) => sharedChannelIds.has(channelId)) - ); + // Community agents explicitly configured for anyone are invocable across + // the community, even before they belong to the channel being composed in. + // An owner/admin mention adds the agent to that channel during the send + // flow. Requiring an existing shared channel here made the directory entry + // disappear precisely when that first invitation was needed. + return agent.respondTo === "anyone"; } export function getMentionableAgentPubkeys({ @@ -54,13 +118,16 @@ export function getMentionableAgentPubkeys({ return pubkeys; } -export function isAgentIdentityInManagedList( +export function isAgentIdentityInKnownDirectories( candidate: { isAgent?: boolean; pubkey: string }, managedAgentPubkeys: ReadonlySet, + relayAgentPubkeys: ReadonlySet = new Set(), ) { + const pubkey = normalizePubkey(candidate.pubkey); return ( candidate.isAgent !== true || - managedAgentPubkeys.has(normalizePubkey(candidate.pubkey)) + managedAgentPubkeys.has(pubkey) || + relayAgentPubkeys.has(pubkey) ); } diff --git a/desktop/src/features/agents/lib/agentSurfaceMapContract.test.mjs b/desktop/src/features/agents/lib/agentSurfaceMapContract.test.mjs new file mode 100644 index 0000000000..a7a9c34ea9 --- /dev/null +++ b/desktop/src/features/agents/lib/agentSurfaceMapContract.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url)); + +function read(relativePath) { + return readFileSync(`${repoRoot}/${relativePath}`, "utf8"); +} + +const surfaceMap = read("docs/agent-surface-map.md"); + +function declaredRoutes(source) { + return [ + "/", + ...[...source.matchAll(/route\(\s*["']([^"']+)["']/g)].map( + (match) => match[1], + ), + ]; +} + +test("agent surface map inventories every declared desktop and web route", () => { + const routeSources = [ + read("desktop/src/app/routes.ts"), + read("web/src/app/routes.ts"), + ]; + + for (const route of new Set(routeSources.flatMap(declaredRoutes))) { + assert.equal( + surfaceMap.includes(`\`${route}\``), + true, + `docs/agent-surface-map.md must classify route ${route}`, + ); + } +}); + +test("desktop mention and search surfaces share the canonical access policy", () => { + const mentionSource = read( + "desktop/src/features/messages/lib/useMentions.ts", + ); + const searchSource = read("desktop/src/features/search/useSearchResults.ts"); + + assert.match( + mentionSource, + /getMentionableAgentPubkeys/, + "mentions must delegate to the canonical mentionable-agent projection", + ); + assert.match( + searchSource, + /relayAgentIsSharedWithUser/, + "global search must use relayAgentIsSharedWithUser", + ); + + assert.doesNotMatch( + searchSource, + /\.respondTo\s*[!=]==?\s*["']anyone["']/, + "global search must not recreate agent access policy inline", + ); +}); + +test("historical Inbox presentation overlays the current hosted directory", () => { + const homeSource = read("desktop/src/features/home/ui/HomeView.tsx"); + assert.match(homeSource, /useRelayAgentDirectory/); + assert.match(homeSource, /overlayHostedAgentProfiles/); + assert.match( + surfaceMap, + /historical events change presentation without being rewritten/i, + ); +}); + +test("desktop and web hosted configuration readers remain represented", () => { + const desktopReader = read( + "desktop/src-tauri/src/commands/agent_discovery.rs", + ); + const webReader = read("web/src/features/workspace/workspace-api.ts"); + + for (const [surface, source] of [ + ["desktop", desktopReader], + ["web", webReader], + ]) { + assert.match( + source, + /KIND_HOSTED_AGENT_CONFIG/, + `${surface} must read hosted-agent configuration`, + ); + assert.match( + source, + /KIND_MANAGED_AGENT/, + `${surface} must retain the old-relay compatibility reader`, + ); + } +}); diff --git a/desktop/src/features/agents/lib/hostedAgentConfig.ts b/desktop/src/features/agents/lib/hostedAgentConfig.ts new file mode 100644 index 0000000000..00e94ec971 --- /dev/null +++ b/desktop/src/features/agents/lib/hostedAgentConfig.ts @@ -0,0 +1,75 @@ +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent } from "@/shared/api/tauri"; +import { + KIND_HOSTED_AGENT_CONFIG, + KIND_MANAGED_AGENT, +} from "@/shared/constants/kinds"; + +const HOSTED_AGENT_CONFIG_SCHEMA = "buzz.hosted-agent-config.v1"; + +export type HostedAgentConfigInput = { + pubkey: string; + name: string; + avatarUrl: string | null; + model: string | null; +}; + +function isUnknownKindError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /unknown event kind/i.test(message); +} + +async function publishConfigEvent( + kind: number, + dTag: string, + content: string, +): Promise { + const event = await signRelayEvent({ + kind, + tags: [["d", dTag]], + content, + }); + await relayClient.publishEvent( + event, + "Timed out while saving the hosted agent.", + "Could not save the hosted agent.", + ); +} + +/** + * Publish the current administrator's durable presentation/runtime preference + * for one hosted agent. The agent pubkey is the NIP-33 coordinate; no agent + * secret or provider credential ever crosses this boundary. + */ +export async function publishHostedAgentConfig( + input: HostedAgentConfigInput, +): Promise { + const name = input.name.trim(); + if (!name) throw new Error("Agent name is required."); + + const pubkey = input.pubkey.toLowerCase(); + const content = JSON.stringify({ + schema: HOSTED_AGENT_CONFIG_SCHEMA, + agent_pubkey: pubkey, + name, + avatar_url: input.avatarUrl?.trim() || null, + model: input.model?.trim() || null, + }); + + try { + await publishConfigEvent(KIND_HOSTED_AGENT_CONFIG, pubkey, content); + } catch (error) { + if (!isUnknownKindError(error)) throw error; + + // Compatibility path for relays deployed before kind:30179 existed. + // Kind:30177 is already an owner-authored, global NIP-33 document. A + // namespaced d-tag and schema marker keep this projection disjoint from + // real managed-agent definitions while allowing mixed-version fleets to + // save names, avatars, and model preferences immediately. + await publishConfigEvent( + KIND_MANAGED_AGENT, + `hosted-agent:${pubkey}`, + content, + ); + } +} diff --git a/desktop/src/features/agents/lib/hostedAgentModelCatalog.test.mjs b/desktop/src/features/agents/lib/hostedAgentModelCatalog.test.mjs new file mode 100644 index 0000000000..d6c117637c --- /dev/null +++ b/desktop/src/features/agents/lib/hostedAgentModelCatalog.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { hostedAgentModelGroups } from "./hostedAgentModelCatalog.ts"; + +test("keeps Claude Code and Codex choices when the hosted catalog is empty", () => { + const groups = hostedAgentModelGroups([]); + + assert.deepEqual( + groups.map((group) => [ + group.label, + group.options.map((option) => option.name), + ]), + [ + ["Claude Code", ["Opus", "Fable"]], + ["Codex", ["Sol", "Luna", "Terra"]], + ], + ); +}); + +test("merges live models into their provider group without duplicates", () => { + const groups = hostedAgentModelGroups([ + { + id: "gpt-5.6-sol", + name: "Sol (live)", + description: "Runtime-advertised", + }, + { id: "claude-sonnet-4-6", name: "Sonnet", description: null }, + { id: "private-model", name: "Private", description: null }, + ]); + + assert.equal( + groups + .find((group) => group.label === "Codex") + ?.options.find((option) => option.id === "gpt-5.6-sol")?.name, + "Sol (live)", + ); + assert.ok( + groups + .find((group) => group.label === "Claude Code") + ?.options.some((option) => option.name === "Sonnet"), + ); + assert.deepEqual( + groups.find((group) => group.label === "Agent-reported models")?.options, + [{ id: "private-model", name: "Private", description: null }], + ); +}); diff --git a/desktop/src/features/agents/lib/hostedAgentModelCatalog.ts b/desktop/src/features/agents/lib/hostedAgentModelCatalog.ts new file mode 100644 index 0000000000..a2d7cb78f0 --- /dev/null +++ b/desktop/src/features/agents/lib/hostedAgentModelCatalog.ts @@ -0,0 +1,81 @@ +import type { AgentModelInfo } from "@/shared/api/types"; + +export type HostedAgentModelGroup = { + id: string; + label: string; + options: AgentModelInfo[]; +}; + +const FALLBACK_GROUPS: readonly HostedAgentModelGroup[] = [ + { + id: "claude-code", + label: "Claude Code", + options: [ + { id: "claude-opus-4-6", name: "Opus", description: null }, + { id: "claude-fable-5", name: "Fable", description: null }, + ], + }, + { + id: "codex", + label: "Codex", + options: [ + { id: "gpt-5.6-sol", name: "Sol", description: null }, + { id: "gpt-5.6-luna", name: "Luna", description: null }, + { id: "gpt-5.6-terra", name: "Terra", description: null }, + ], + }, +]; + +function providerGroupId(model: AgentModelInfo): string { + const haystack = `${model.id} ${model.name ?? ""}`.toLowerCase(); + if (/claude|opus|sonnet|haiku|fable/.test(haystack)) return "claude-code"; + if (/codex|gpt|sol|luna|terra/.test(haystack)) return "codex"; + return "agent-reported"; +} + +/** + * Merge the hosted runtime's live ACP model catalog with Buzz's provider + * choices. Live metadata wins on labels; the fallback keeps the editor useful + * while older hosted containers are being rolled forward. + */ +export function hostedAgentModelGroups( + advertised: readonly AgentModelInfo[] | null | undefined, +): HostedAgentModelGroup[] { + const groups = FALLBACK_GROUPS.map((group) => ({ + ...group, + options: group.options.map((option) => ({ ...option })), + })); + const byId = new Map(groups.map((group) => [group.id, group])); + const seen = new Set( + groups.flatMap((group) => group.options.map((option) => option.id)), + ); + + for (const model of advertised ?? []) { + const id = model.id.trim(); + if (!id) continue; + const groupId = providerGroupId(model); + let group = byId.get(groupId); + if (!group) { + group = { id: groupId, label: "Agent-reported models", options: [] }; + groups.push(group); + byId.set(groupId, group); + } + + const existing = groups + .flatMap((candidate) => candidate.options) + .find((option) => option.id === id); + if (existing) { + existing.name = model.name?.trim() || existing.name; + continue; + } + if (seen.has(id)) continue; + seen.add(id); + group.options.push({ + id, + name: model.name?.trim() || id, + description: model.description, + }); + } + + return groups.filter((group) => group.options.length > 0); +} diff --git a/desktop/src/features/agents/lib/hostedAgentPresentation.test.mjs b/desktop/src/features/agents/lib/hostedAgentPresentation.test.mjs index e3e2f04981..88034b4218 100644 --- a/desktop/src/features/agents/lib/hostedAgentPresentation.test.mjs +++ b/desktop/src/features/agents/lib/hostedAgentPresentation.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getHostedAgentPresentation } from "./hostedAgentPresentation.ts"; +import { + getHostedAgentPresentation, + overlayHostedAgentProfiles, +} from "./hostedAgentPresentation.ts"; const agent = { pubkey: "a".repeat(64), @@ -37,3 +40,37 @@ test("hosted presentation falls back to kind:0 only when directory metadata is a }, ); }); + +test("hosted directory identity overlays stale Inbox profiles by pubkey", () => { + const pubkey = agent.pubkey.toUpperCase(); + const profiles = { + [agent.pubkey]: { + displayName: "Founder Chief of Staff", + name: "founder-chief-of-staff", + avatarUrl: "https://relay.example/founder-old.png", + nip05Handle: "assistant@example.com", + ownerPubkey: null, + isAgent: true, + }, + ["b".repeat(64)]: { + displayName: "A human", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }; + + const result = overlayHostedAgentProfiles(profiles, [ + { ...agent, pubkey, ownerPubkey: "c".repeat(64) }, + ]); + + assert.deepEqual(result?.[agent.pubkey], { + displayName: "Lanaya", + name: "founder-chief-of-staff", + avatarUrl: "https://relay.example/lanaya-current.png", + nip05Handle: "assistant@example.com", + ownerPubkey: "c".repeat(64), + isAgent: true, + }); + assert.equal(result?.["b".repeat(64)], profiles["b".repeat(64)]); +}); diff --git a/desktop/src/features/agents/lib/hostedAgentPresentation.ts b/desktop/src/features/agents/lib/hostedAgentPresentation.ts index 496bca6c4c..8d93a08b6b 100644 --- a/desktop/src/features/agents/lib/hostedAgentPresentation.ts +++ b/desktop/src/features/agents/lib/hostedAgentPresentation.ts @@ -1,4 +1,5 @@ -import type { RelayAgent } from "@/shared/api/types"; +import type { RelayAgent, UserProfileSummary } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; type ProfileFallback = { avatarUrl: string | null; @@ -30,3 +31,32 @@ export function getHostedAgentPresentation( firstNonBlank(agent.name, profile?.displayName) ?? "Hosted agent", }; } + +/** + * Overlays current hosted-directory presentation onto historical kind:0 + * profiles. Inbox and message history keep the event author's pubkey, so a + * renamed/rebranded agent should immediately render with its current identity. + */ +export function overlayHostedAgentProfiles( + profiles: Record | undefined, + agents: readonly RelayAgent[], +): Record | undefined { + if (agents.length === 0) return profiles; + + const overlaid = { ...(profiles ?? {}) }; + for (const agent of agents) { + const pubkey = normalizePubkey(agent.pubkey); + const existing = overlaid[pubkey]; + const presentation = getHostedAgentPresentation(agent, existing); + overlaid[pubkey] = { + ...existing, + avatarUrl: presentation.avatarUrl, + displayName: presentation.displayName, + isAgent: true, + ownerPubkey: agent.ownerPubkey ?? existing?.ownerPubkey ?? null, + nip05Handle: existing?.nip05Handle ?? null, + }; + } + + return overlaid; +} diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 1bd8af8976..74110ca4f7 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -774,9 +774,13 @@ export function AgentConfigFields({ {modelControlVisible ? (
- {/* Avatar is definition-level identity. hideEditControl suppresses - the internal pencil badge; the CTA below is the only edit path. */} + {/* An instance can carry an explicit avatar override. Keeping the + picker here makes the profile's Edit action complete even for + built-in or otherwise non-editable definitions. */}
setAvatarUrl("")} onUploadPendingChange={setIsAvatarUploadPending} onSelectAvatar={setAvatarUrl} + testIdPrefix="edit-agent-avatar" /> {onEditLinkedPersona ? ( ) : (

- Avatar is shared identity + This picture is used on the agent profile.

)}
diff --git a/desktop/src/features/agents/ui/HostedAgentEditDialog.tsx b/desktop/src/features/agents/ui/HostedAgentEditDialog.tsx new file mode 100644 index 0000000000..6bcfca22f5 --- /dev/null +++ b/desktop/src/features/agents/ui/HostedAgentEditDialog.tsx @@ -0,0 +1,251 @@ +import * as React from "react"; +import { ImagePlus } from "lucide-react"; + +import { publishHostedAgentConfig } from "@/features/agents/lib/hostedAgentConfig"; +import { hostedAgentModelGroups } from "@/features/agents/lib/hostedAgentModelCatalog"; +import { switchManagedAgentModel } from "@/shared/api/agentControl"; +import { useAvatarUpload } from "@/features/profile/useAvatarUpload"; +import type { RelayAgent } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +export function HostedAgentEditDialog({ + agent, + onOpenChange, + onSaved, + open, +}: { + agent: RelayAgent; + onOpenChange: (open: boolean) => void; + onSaved: () => Promise | void; + open: boolean; +}) { + const modelGroups = React.useMemo( + () => hostedAgentModelGroups(agent.models), + [agent.models], + ); + const knownModelIds = React.useMemo( + () => + new Set( + modelGroups.flatMap((group) => group.options.map(({ id }) => id)), + ), + [modelGroups], + ); + const [name, setName] = React.useState(agent.name); + const [avatarUrl, setAvatarUrl] = React.useState(agent.avatarUrl ?? ""); + const knownModel = agent.model ? knownModelIds.has(agent.model) : false; + const [modelChoice, setModelChoice] = React.useState( + agent.model ? (knownModel ? agent.model : "custom") : "runtime-default", + ); + const [customModel, setCustomModel] = React.useState( + agent.model && !knownModel ? agent.model : "", + ); + const [saving, setSaving] = React.useState(false); + const [error, setError] = React.useState(null); + const avatarUpload = useAvatarUpload({ + onUploadSuccess: setAvatarUrl, + }); + + React.useEffect(() => { + if (!open) return; + setName(agent.name); + setAvatarUrl(agent.avatarUrl ?? ""); + const nextKnown = agent.model ? knownModelIds.has(agent.model) : false; + setModelChoice( + agent.model ? (nextKnown ? agent.model : "custom") : "runtime-default", + ); + setCustomModel(agent.model && !nextKnown ? agent.model : ""); + setError(null); + }, [agent, knownModelIds, open]); + + const selectedModel = + modelChoice === "runtime-default" + ? null + : modelChoice === "custom" + ? customModel.trim() || null + : modelChoice; + + return ( + + + + Edit hosted agent + + Name and picture update everywhere in Buzz. The model is saved for + this agent instead of changing the global agent default. + + + +
+
+ +
+ + + {avatarUpload.errorMessage ? ( +

+ {avatarUpload.errorMessage} +

+ ) : avatarUrl !== (agent.avatarUrl ?? "") ? ( +

+ Picture ready. Save the agent to apply it everywhere. +

+ ) : null} + {avatarUrl ? ( + + ) : null} +
+
+ +
+ + setName(event.target.value)} + value={name} + /> +

+ Mentions, channel messages, member lists, and this profile use the + same name. +

+
+ +
+ + + {modelChoice === "custom" ? ( + setCustomModel(event.target.value)} + placeholder="Provider model ID" + value={customModel} + /> + ) : null} +

+ Claude Code and Codex choices remain available while older agents + load their live catalog. Runtime default follows the agent's + current provider configuration. +

+
+ + {error ?

{error}

: null} +
+ + + + + +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/WhereToRunSection.tsx b/desktop/src/features/agents/ui/WhereToRunSection.tsx index f068eceec8..ee9ec37132 100644 --- a/desktop/src/features/agents/ui/WhereToRunSection.tsx +++ b/desktop/src/features/agents/ui/WhereToRunSection.tsx @@ -5,7 +5,11 @@ import { useBackendProvidersQuery } from "@/features/agents/hooks"; import { probeBackendProvider } from "@/shared/api/tauri"; import { ProviderConfigFields } from "./ProviderConfigFields"; -import { emptyWhereToRunDraft, type WhereToRunDraft } from "./whereToRunIntent"; +import { + applyProbeResult, + emptyWhereToRunDraft, + type WhereToRunDraft, +} from "./whereToRunIntent"; /** Optional remote-backend selector. Buzz shared compute is an LLM provider, not a run destination. */ export function WhereToRunSection({ @@ -26,32 +30,37 @@ export function WhereToRunSection({ [backendProviders, draft.runOn], ); + // Latest-state seam for probe resolution: an Effect Event always sees the + // draft as it is *now*. Without this, the probe promise closes over the + // draft from probe start, and anything typed while the probe was in flight + // gets thrown away when it resolves (a second, subtler Typewriter Eraser). + const applyProbe = React.useEffectEvent( + (result: Awaited>) => { + onDraftChange(applyProbeResult(draft, result)); + }, + ); + + // Probe once per provider *selection*, keyed on the provider's stable + // path — never on the draft. Depending on the draft made every keystroke + // refire the probe, and each resolution reset providerConfig to schema + // defaults, which erased what the user was typing (the Typewriter Eraser) + // and spawned the provider binary in a loop for as long as the dialog was + // open. Keying on the path (not the provider object) also keeps a + // providers-query refresh from reprobing an unchanged selection. + const selectedBinaryPath = isProviderMode + ? (selectedBackendProvider?.binaryPath ?? null) + : null; React.useEffect(() => { - if (!isProviderMode || !selectedBackendProvider) { + if (!selectedBinaryPath) { setProbeError(null); return; } let cancelled = false; setProbeError(null); - void probeBackendProvider(selectedBackendProvider.binaryPath) + void probeBackendProvider(selectedBinaryPath) .then((result) => { if (cancelled) return; - const defaults: Record = {}; - const properties = - (result.config_schema as Record | undefined) - ?.properties ?? {}; - for (const [key, property] of Object.entries(properties) as [ - string, - Record, - ][]) { - if (property.default != null) - defaults[key] = String(property.default); - } - onDraftChange({ - ...draft, - probedProvider: result, - providerConfig: defaults, - }); + applyProbe(result); }) .catch((error: unknown) => { if (!cancelled) { @@ -61,7 +70,7 @@ export function WhereToRunSection({ return () => { cancelled = true; }; - }, [draft, isProviderMode, onDraftChange, selectedBackendProvider]); + }, [selectedBinaryPath]); if (backendProviders.length === 0) return null; diff --git a/desktop/src/features/agents/ui/whereToRunIntent.test.mjs b/desktop/src/features/agents/ui/whereToRunIntent.test.mjs index 500e9019f2..262d55d998 100644 --- a/desktop/src/features/agents/ui/whereToRunIntent.test.mjs +++ b/desktop/src/features/agents/ui/whereToRunIntent.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + applyProbeResult, canSubmitWhereToRun, emptyWhereToRunDraft, providerConfigComplete, @@ -59,3 +60,72 @@ test("provider draft resolves with coerced config values", () => { config: { region: "us", size: 3 }, }); }); + +// ── applyProbeResult: probe resolution must merge, not overwrite ───────────── +// +// Pins the seam that fixed the "Typewriter Eraser" (agent-create dialog's +// provider config fields losing keystrokes): a probe resolution prefills +// schema defaults *beneath* the user's in-flight config, never over it. The +// effect in WhereToRunSection keys probing on the provider's binary path, so +// the only probe writes that reach providerConfig are the ones pinned here. + +const probeWithDefaults = { + ok: true, + config_schema: { + properties: { + context: { type: "string", title: "Kubeconfig context" }, + namespace: { type: "string", default: "buzz-agents-x1y2z3" }, + inactivity_seconds: { type: "number", default: 1800 }, + }, + required: ["namespace"], + }, +}; + +const unprobedDraft = { + ...emptyWhereToRunDraft, + runOn: "kubernetes", +}; + +test("probe resolution prefills schema defaults on a fresh draft", () => { + const next = applyProbeResult(unprobedDraft, probeWithDefaults); + assert.equal(next.probedProvider, probeWithDefaults); + assert.deepEqual(next.providerConfig, { + namespace: "buzz-agents-x1y2z3", + inactivity_seconds: "1800", + }); +}); + +test("probe resolution keeps user-typed values over schema defaults", () => { + const typed = { + ...unprobedDraft, + providerConfig: { context: "prod-us-west", namespace: "my-ns" }, + }; + const next = applyProbeResult(typed, probeWithDefaults); + assert.deepEqual(next.providerConfig, { + context: "prod-us-west", + namespace: "my-ns", + inactivity_seconds: "1800", + }); +}); + +test("probe resolution keeps a user-cleared field cleared", () => { + // "" is a deliberate user state — coerceConfigValues drops empty numerics + // and required-gating treats "" as incomplete; the probe must not undo it. + const cleared = { ...unprobedDraft, providerConfig: { namespace: "" } }; + const next = applyProbeResult(cleared, probeWithDefaults); + assert.equal(next.providerConfig.namespace, ""); +}); + +test("a schema-less probe result records the probe without touching config", () => { + const typed = { ...unprobedDraft, providerConfig: { context: "abc" } }; + const next = applyProbeResult(typed, { ok: true }); + assert.deepEqual(next.providerConfig, { context: "abc" }); + assert.deepEqual(next.probedProvider, { ok: true }); +}); + +test("probe resolution preserves unrelated draft fields", () => { + assert.equal( + applyProbeResult(unprobedDraft, probeWithDefaults).runOn, + "kubernetes", + ); +}); diff --git a/desktop/src/features/agents/ui/whereToRunIntent.ts b/desktop/src/features/agents/ui/whereToRunIntent.ts index fcb3e82b7e..9aa9248e75 100644 --- a/desktop/src/features/agents/ui/whereToRunIntent.ts +++ b/desktop/src/features/agents/ui/whereToRunIntent.ts @@ -15,6 +15,35 @@ export const emptyWhereToRunDraft: WhereToRunDraft = { probedProvider: null, }; +/** + * Fold a completed probe into the draft the user has *now* — not the draft + * that existed when the probe started. Schema defaults prefill only the keys + * the user has not touched: anything already in `providerConfig` (typed while + * the probe was in flight) wins over the default. Overwriting instead of + * merging is the "Typewriter Eraser" bug — every probe resolution silently + * erased in-flight keystrokes. + */ +export function applyProbeResult( + current: WhereToRunDraft, + result: BackendProviderProbeResult, +): WhereToRunDraft { + const defaults: Record = {}; + const properties = + (result.config_schema as Record | undefined)?.properties ?? + {}; + for (const [key, property] of Object.entries(properties) as [ + string, + Record, + ][]) { + if (property.default != null) defaults[key] = String(property.default); + } + return { + ...current, + probedProvider: result, + providerConfig: { ...defaults, ...current.providerConfig }, + }; +} + export function providerConfigComplete(draft: WhereToRunDraft): boolean { if (draft.runOn === "local") return true; if (!draft.probedProvider) return false; diff --git a/desktop/src/features/agents/useKnownAgentPubkeys.tsx b/desktop/src/features/agents/useKnownAgentPubkeys.tsx index e9fe7b9a9b..620812009f 100644 --- a/desktop/src/features/agents/useKnownAgentPubkeys.tsx +++ b/desktop/src/features/agents/useKnownAgentPubkeys.tsx @@ -5,13 +5,17 @@ import { useRelayAgentsQuery, } from "@/features/agents/hooks"; import { mergeKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; +import type { RelayAgent } from "@/shared/api/types"; import { useStableSet } from "@/shared/hooks/useStableReference"; const EMPTY_KNOWN_AGENT_PUBKEYS: ReadonlySet = new Set(); +const EMPTY_RELAY_AGENTS: readonly RelayAgent[] = []; const KnownAgentPubkeysContext = React.createContext>( EMPTY_KNOWN_AGENT_PUBKEYS, ); +const RelayAgentDirectoryContext = + React.createContext(EMPTY_RELAY_AGENTS); /** * Owns the app's only React Query subscription to the known-agent source @@ -40,7 +44,7 @@ export function KnownAgentPubkeysProvider({ children: React.ReactNode; }) { const managedAgents = useManagedAgentsQuery().data; - const relayAgents = useRelayAgentsQuery().data; + const relayAgents = useRelayAgentsQuery().data ?? EMPTY_RELAY_AGENTS; const merged = React.useMemo( () => mergeKnownAgentPubkeys(managedAgents, relayAgents), @@ -50,7 +54,9 @@ export function KnownAgentPubkeysProvider({ return ( - {children} + + {children} + ); } @@ -82,3 +88,8 @@ export function KnownAgentPubkeysProvider({ export function useKnownAgentPubkeys(): ReadonlySet { return React.useContext(KnownAgentPubkeysContext); } + +/** Current hosted-agent directory records from the provider's shared query. */ +export function useRelayAgentDirectory(): readonly RelayAgent[] { + return React.useContext(RelayAgentDirectoryContext); +} diff --git a/desktop/src/features/channels/readState/readStateManager.test.mjs b/desktop/src/features/channels/readState/readStateManager.test.mjs index 89d092fae9..71d3e26fd0 100644 --- a/desktop/src/features/channels/readState/readStateManager.test.mjs +++ b/desktop/src/features/channels/readState/readStateManager.test.mjs @@ -305,6 +305,33 @@ test("trimContextsToBudget_msgEvictedBeforeThread", () => { assert.ok(`thread:${THREAD_ID}` in contexts, "thread entry should survive"); }); +test("trimContextsToBudget_evictsInboxDismissalsBeforeChannelState", () => { + const dismissKey = `inbox-dismiss:${MSG_ID}`; + const contexts = { + [dismissKey]: 1, + "channel:some-channel-id": 2, + }; + const encoder = new TextEncoder(); + const budget = encoder.encode( + JSON.stringify({ + v: 1, + client_id: CLIENT_ID, + contexts: { "channel:some-channel-id": 2 }, + }), + ).length; + + const { evicted, fitsAfterTrim } = trimContextsToBudget( + contexts, + CLIENT_ID, + budget, + ); + + assert.equal(evicted, 1); + assert.equal(fitsAfterTrim, true); + assert.equal(dismissKey in contexts, false); + assert.equal("channel:some-channel-id" in contexts, true); +}); + test("trimContextsToBudget_emptyContexts_returnsZeroAndFits", () => { // Empty contexts: blob is just the skeleton — fits any reasonable budget. const contexts = {}; diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index 382a60f20e..b60511f543 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -243,9 +243,9 @@ export interface TrimResult { /** * Trim a contexts map to fit within `maxBytes` when serialized as the JSON - * blob `{v:1, client_id, contexts}`. Evicts oldest `msg:` entries first - * (lowest timestamp), then oldest `thread:` entries. Channel keys are never - * evicted. Mutates `contexts` in place. + * blob `{v:1, client_id, contexts}`. Evicts oldest per-item entries first + * (Inbox dismissals, then messages), followed by oldest threads. Channel keys + * are never evicted. Mutates `contexts` in place. * * Returns `{ evicted, fitsAfterTrim }`. `fitsAfterTrim` is false when the * remaining blob (channel keys only) still exceeds `maxBytes` — the caller @@ -268,15 +268,19 @@ export function trimContextsToBudget( } const msgEntries: [string, number][] = []; + const inboxDismissEntries: [string, number][] = []; const threadEntries: [string, number][] = []; for (const [key, ts] of Object.entries(contexts)) { - if (key.startsWith(MSG_PREFIX)) { + if (key.startsWith("inbox-dismiss:")) { + inboxDismissEntries.push([key, ts]); + } else if (key.startsWith(MSG_PREFIX)) { msgEntries.push([key, ts]); } else if (key.startsWith(THREAD_PREFIX)) { threadEntries.push([key, ts]); } } // Oldest-first within each tier. + inboxDismissEntries.sort((a, b) => a[1] - b[1]); msgEntries.sort((a, b) => a[1] - b[1]); threadEntries.sort((a, b) => a[1] - b[1]); @@ -285,7 +289,11 @@ export function trimContextsToBudget( // (key.length + 3 bytes for `"`, `"`, `:` plus 1 comma) + timestamp digits. // This is an approximation — the final encode below is the authoritative check. const toEvict: string[] = []; - for (const [key, ts] of [...msgEntries, ...threadEntries]) { + for (const [key, ts] of [ + ...inboxDismissEntries, + ...msgEntries, + ...threadEntries, + ]) { if (currentBytes <= maxBytes) break; // Contribution: `,"key":timestamp` — comma + quoted key + colon + value currentBytes -= key.length + 3 + String(ts).length + 1; diff --git a/desktop/src/features/channels/readState/readStateStorage.test.mjs b/desktop/src/features/channels/readState/readStateStorage.test.mjs index ad7eb031eb..02eb388e10 100644 --- a/desktop/src/features/channels/readState/readStateStorage.test.mjs +++ b/desktop/src/features/channels/readState/readStateStorage.test.mjs @@ -42,7 +42,7 @@ function installLocalStorage() { const NOW = 1_750_000_000; -test("pruneStaleContexts drops msg/thread markers older than horizon", () => { +test("pruneStaleContexts drops per-item markers older than horizon", () => { const cutoff = NOW - READ_STATE_HORIZON_SECONDS; const contexts = new Map([ ["channel-1", cutoff - 999_999], @@ -50,6 +50,8 @@ test("pruneStaleContexts drops msg/thread markers older than horizon", () => { [`thread:${"b".repeat(64)}`, cutoff + 1], [`msg:${"c".repeat(64)}`, cutoff - 1], [`msg:${"d".repeat(64)}`, cutoff + 1], + [`inbox-dismiss:${"e".repeat(64)}`, cutoff - 1], + [`inbox-dismiss:${"f".repeat(64)}`, cutoff + 1], ]); const pruned = pruneStaleContexts(contexts, NOW); @@ -59,6 +61,8 @@ test("pruneStaleContexts drops msg/thread markers older than horizon", () => { assert.equal(pruned.has(`thread:${"b".repeat(64)}`), true); assert.equal(pruned.has(`msg:${"c".repeat(64)}`), false); assert.equal(pruned.has(`msg:${"d".repeat(64)}`), true); + assert.equal(pruned.has(`inbox-dismiss:${"e".repeat(64)}`), false); + assert.equal(pruned.has(`inbox-dismiss:${"f".repeat(64)}`), true); }); test("pruneStaleContexts caps within-horizon prunable entries, newest kept", () => { diff --git a/desktop/src/features/channels/readState/readStateStorage.ts b/desktop/src/features/channels/readState/readStateStorage.ts index f5ac899613..4df1dea505 100644 --- a/desktop/src/features/channels/readState/readStateStorage.ts +++ b/desktop/src/features/channels/readState/readStateStorage.ts @@ -105,12 +105,14 @@ export function readStoredReadState(pubkey: string): StoredReadState { function isPrunableContextKey(contextId: string): boolean { return ( - contextId.startsWith(MSG_PREFIX) || contextId.startsWith(THREAD_PREFIX) + contextId.startsWith(MSG_PREFIX) || + contextId.startsWith(THREAD_PREFIX) || + contextId.startsWith("inbox-dismiss:") ); } /** - * Drops msg:/thread: markers older than the relay's 7-day horizon, then caps + * Drops msg:/thread:/inbox-dismiss: markers older than the relay's 7-day horizon, then caps * the survivors at LOCAL_MAX_PRUNABLE_CONTEXTS (oldest first). Channel keys * are never pruned — they are small, bounded by membership, and losing one * would resurrect the channel's unread badge. Mirrors the eviction order the diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index c6349546a2..905cc77c8c 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -9,7 +9,7 @@ import { import { attachManagedAgentToChannel } from "@/features/agents/channelAgents"; import { coalesceAgentAutocompleteCandidates, - isAgentIdentityInManagedList, + isAgentIdentityInKnownDirectories, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers"; @@ -282,7 +282,7 @@ export function MembersSidebar({ )) || memberPubkeys.has(pubkey) || isArchivedDiscovery(pubkey) || - !isAgentIdentityInManagedList(candidate, managedAgentPubkeys) + !isAgentIdentityInKnownDirectories(candidate, managedAgentPubkeys) ) { return; } diff --git a/desktop/src/features/channels/ui/useChannelActivityTyping.test.mjs b/desktop/src/features/channels/ui/useChannelActivityTyping.test.mjs index 3e3943f43e..edd6a9f594 100644 --- a/desktop/src/features/channels/ui/useChannelActivityTyping.test.mjs +++ b/desktop/src/features/channels/ui/useChannelActivityTyping.test.mjs @@ -10,6 +10,7 @@ import { import { resetActiveAgentTurnsStore } from "../../agents/activeAgentTurnsStore.ts"; import { channelScopedBotTypingPubkeyKey, + mergeAgentNamesIntoProfiles, mergeMemberAgentFlagsIntoProfiles, } from "./useChannelActivityTyping.ts"; @@ -18,6 +19,29 @@ const AGENT = const AGENT_2 = "dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321"; +it("uses hosted admin presentation over stale profile metadata", () => { + const profiles = mergeAgentNamesIntoProfiles( + { + [AGENT]: { + displayName: "Varun Personal Assistant", + avatarUrl: "https://relay.example/old.png", + nip05Handle: null, + }, + }, + [], + [ + { + pubkey: AGENT, + name: "Lanaya", + avatarUrl: "https://relay.example/lanaya.png", + }, + ], + ); + + assert.equal(profiles[AGENT].displayName, "Lanaya"); + assert.equal(profiles[AGENT].avatarUrl, "https://relay.example/lanaya.png"); +}); + describe("channelScopedBotTypingPubkeyKey", () => { it("excludes thread-scoped typing entries", () => { const key = channelScopedBotTypingPubkeyKey([ diff --git a/desktop/src/features/channels/ui/useChannelActivityTyping.ts b/desktop/src/features/channels/ui/useChannelActivityTyping.ts index 0f6807c059..f9444c431a 100644 --- a/desktop/src/features/channels/ui/useChannelActivityTyping.ts +++ b/desktop/src/features/channels/ui/useChannelActivityTyping.ts @@ -140,8 +140,11 @@ export function mergeAgentNamesIntoProfiles( const key = normalizePubkey(agent.pubkey); merged[key] = { ...merged[key], - displayName: merged[key]?.displayName || agent.name, - avatarUrl: merged[key]?.avatarUrl ?? null, + // The relay-agent value already includes the current administrator's + // hosted config projection. It must win over stale kind:0 metadata so + // names and pictures remain identical in Agents, profiles, and messages. + displayName: agent.name || merged[key]?.displayName, + avatarUrl: agent.avatarUrl ?? merged[key]?.avatarUrl ?? null, nip05Handle: merged[key]?.nip05Handle ?? null, isAgent: true, }; diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index e34fa0c200..8c865e60ba 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -20,8 +20,15 @@ import type { } from "@/shared/api/types"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; +export const INBOX_DISMISS_CONTEXT_PREFIX = "inbox-dismiss:"; + +export function getInboxDismissContextId(approvalEventId: string) { + return `${INBOX_DISMISS_CONTEXT_PREFIX}${approvalEventId}`; +} + export type InboxFilter = | "all" + | "alerts" | "project" | "mention" | "thread" diff --git a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs index 2ac4148b92..a91eb50076 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs +++ b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs @@ -15,12 +15,30 @@ import { toTimelineMessage, } from "./inboxViewHelpers.ts"; -test("Inbox uses the dedicated reminder list instead of feed reminder rows", () => { - const message = { item: { kind: 9 } }; - const reminder = { item: { kind: 40007 } }; - const items = [message, reminder]; +test("Inbox contains only explicit approval requests", () => { + const mention = { item: { kind: 9 }, groupItems: [{ kind: 9 }] }; + const reminder = { + item: { kind: 40007 }, + groupItems: [{ kind: 40007 }], + }; + const agentUpdate = { + item: { kind: 43003 }, + groupItems: [{ kind: 43003 }], + }; + const approval = { + item: { kind: 46010 }, + groupItems: [{ kind: 46010 }], + }; + const approvalWithNewerReply = { + item: { kind: 9 }, + groupItems: [{ kind: 46010 }, { kind: 9 }], + }; + const items = [mention, reminder, agentUpdate, approval]; - assert.deepEqual(filterInboxItems(items), [message]); + assert.deepEqual(filterInboxItems(items), [approval]); + assert.deepEqual(filterInboxItems([approvalWithNewerReply]), [ + approvalWithNewerReply, + ]); }); test("hasInboxThreadContext finds replies in the grouped row or loaded context", () => { diff --git a/desktop/src/features/home/lib/inboxViewHelpers.ts b/desktop/src/features/home/lib/inboxViewHelpers.ts index 4429a6d15c..594f9759ce 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.ts +++ b/desktop/src/features/home/lib/inboxViewHelpers.ts @@ -16,7 +16,7 @@ import type { RelayEvent, UserProfileSummary, } from "@/shared/api/types"; -import { KIND_REMINDER } from "@/shared/constants/kinds"; +import { KIND_APPROVAL_REQUEST } from "@/shared/constants/kinds"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; @@ -26,7 +26,18 @@ function hasThreadReplyTags(tags: string[][]) { } export function filterInboxItems(items: InboxItem[]) { - return items.filter((item) => item.item.kind !== KIND_REMINDER); + // Inbox is a decision queue, not a second copy of chat. Mentions, DMs, + // thread replies, reminders, and routine agent updates stay on their native + // surfaces. Only an explicit workflow approval request belongs here. + return items.filter((item) => getInboxApprovalRequest(item) !== null); +} + +export function getInboxApprovalRequest(item: InboxItem) { + return ( + item.groupItems.find( + (candidate) => candidate.kind === KIND_APPROVAL_REQUEST, + ) ?? null + ); } export function hasInboxThreadContext( @@ -51,6 +62,15 @@ export function matchesInboxFilter( return matchesInboxAllView(item); } + if (filter === "alerts") { + return ( + item.categories.includes("mention") || + [item.item, ...(item.groupItems ?? [])].some((groupItem) => + groupItem ? hasThreadReplyTags(groupItem.tags) : false, + ) + ); + } + if (filter === "thread") { return [item.item, ...(item.groupItems ?? [])].some((groupItem) => groupItem ? hasThreadReplyTags(groupItem.tags) : false, diff --git a/desktop/src/features/home/ui/HomeScreen.tsx b/desktop/src/features/home/ui/HomeScreen.tsx index b6512816e3..65f1118e1c 100644 --- a/desktop/src/features/home/ui/HomeScreen.tsx +++ b/desktop/src/features/home/ui/HomeScreen.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { useAppShell } from "@/app/AppShellContext"; import { useHomeFeedQuery } from "@/features/home/hooks"; import { HomeView } from "@/features/home/ui/HomeView"; +import type { InboxFilter } from "@/features/home/lib/inbox"; import type { HomeFeedResponse } from "@/shared/api/types"; import { isRelayUnreachableError, @@ -12,6 +13,7 @@ import { type HomeScreenProps = { availableChannelIds: ReadonlySet; currentPubkey?: string; + initialFilter?: InboxFilter; onOpenContext: ( channelId: string, messageId: string, @@ -22,6 +24,7 @@ type HomeScreenProps = { export function HomeScreen({ availableChannelIds, currentPubkey, + initialFilter, onOpenContext, }: HomeScreenProps) { const homeFeedQuery = useHomeFeedQuery(); @@ -50,6 +53,7 @@ export function HomeScreen({ void; onRefresh: () => void; + initialFilter?: InboxFilter; }; export function HomeView({ @@ -100,13 +107,14 @@ export function HomeView({ availableChannelIds, onOpenContext, onRefresh, + initialFilter = "all", }: HomeViewProps) { const relaySelfPubkey = useRelaySelfQuery().data; const [homeInboxRef, homeInboxWidthPx] = useElementWidth(); const isNarrowHomeViewport = homeInboxWidthPx > 0 && homeInboxWidthPx < INBOX_SINGLE_COLUMN_BREAKPOINT_PX; - const [filter, setFilter] = React.useState("all"); + const [filter, setFilter] = React.useState(initialFilter); const [unreadOnly, setUnreadOnly] = React.useState(false); // Explicit selections are mirrored to the URL (`?item=`), so back/forward // restores the detail pane each history entry was showing and reloads @@ -117,7 +125,8 @@ export function HomeView({ const isReminders = filter === "reminders"; const isDrafts = filter === "drafts"; const isMessagesMode = !isReminders && !isDrafts; - const allowMixedPersonalSelection = filter === "all"; + const includePersonalItems = initialFilter !== "all"; + const allowMixedPersonalSelection = includePersonalItems && filter === "all"; const { drafts: { activeCount: activeDraftCount, @@ -321,20 +330,25 @@ export function HomeView({ enabled: feedProfilePubkeys.length > 0, }); const feedProfiles = feedProfilesQuery.data?.profiles; + const relayAgentDirectory = useRelayAgentDirectory(); + const effectiveFeedProfiles = React.useMemo( + () => overlayHostedAgentProfiles(feedProfiles, relayAgentDirectory), + [feedProfiles, relayAgentDirectory], + ); const ownedAgentPubkeys = useOwnedAgentPubkeys( true, - feedProfiles, + effectiveFeedProfiles, currentPubkey, ); const feedOwnerPubkeys = React.useMemo( () => [ ...new Set( - Object.values(feedProfiles ?? {}) + Object.values(effectiveFeedProfiles ?? {}) .map((profile) => profile.ownerPubkey) .filter((pubkey): pubkey is string => Boolean(pubkey)), ), ], - [feedProfiles], + [effectiveFeedProfiles], ); const feedOwnerProfilesQuery = useUsersBatchQuery(feedOwnerPubkeys, { enabled: feedOwnerPubkeys.length > 0, @@ -344,14 +358,16 @@ export function HomeView({ const inboxAgentPubkeys = React.useMemo(() => { const pubkeys = new Set(communityAgentPubkeys); - for (const [pubkey, profile] of Object.entries(feedProfiles ?? {})) { + for (const [pubkey, profile] of Object.entries( + effectiveFeedProfiles ?? {}, + )) { if (profile.isAgent) { pubkeys.add(normalizePubkey(pubkey)); } } return pubkeys; - }, [feedProfiles, communityAgentPubkeys]); + }, [effectiveFeedProfiles, communityAgentPubkeys]); // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion invalidates the stable getChannelReadAt callback const inboxItems = React.useMemo(() => { const items = buildInboxItems({ @@ -361,17 +377,27 @@ export function HomeView({ getChannelReadAt, getMessageReadAt, getThreadReadAt, - profiles: feedProfiles, + profiles: effectiveFeedProfiles, + }); + const surfaceItems = + initialFilter === "alerts" ? items : filterInboxItems(items); + return surfaceItems.filter((item) => { + if (initialFilter === "alerts") return true; + const approval = getInboxApprovalRequest(item); + if (!approval) return false; + const dismissedAt = + getChannelReadAt(getInboxDismissContextId(approval.id)) ?? 0; + return dismissedAt < approval.createdAt; }); - return filterInboxItems(items); }, [ channels, currentPubkey, feed, - feedProfiles, + effectiveFeedProfiles, getChannelReadAt, getMessageReadAt, getThreadReadAt, + initialFilter, readStateVersion, ]); const { effectiveDoneSet, markItemRead, markItemUnread } = @@ -391,6 +417,29 @@ export function HomeView({ undoDoneLocal: undoDone, undoUnreadLocal: undoUnread, }); + const dismissInboxItem = React.useCallback( + (itemId: string) => { + const item = findInboxItemByEventId(inboxItems, itemId); + if (!item) return; + const approval = getInboxApprovalRequest(item); + if (!approval) return; + markChannelRead( + getInboxDismissContextId(approval.id), + new Date(approval.createdAt * 1_000).toISOString(), + ); + }, + [inboxItems, markChannelRead], + ); + const dismissAllInboxItems = React.useCallback(() => { + for (const item of inboxItems) { + const approval = getInboxApprovalRequest(item); + if (!approval) continue; + markChannelRead( + getInboxDismissContextId(approval.id), + new Date(approval.createdAt * 1_000).toISOString(), + ); + } + }, [inboxItems, markChannelRead]); // Resolve selection before filtering so unread-only can retain its active row. const selectedItemFromAll = React.useMemo( () => @@ -454,7 +503,7 @@ export function HomeView({ currentPubkey, events: threadContext.events, ownerProfiles: feedOwnerProfiles, - profiles: feedProfiles, + profiles: effectiveFeedProfiles, reactionEvents: threadContext.reactionEvents, relaySelfPubkey, selectedChannel, @@ -642,8 +691,12 @@ export function HomeView({ doneSet={effectiveDoneSet} dueReminderCount={dueReminderCount} filter={filter} + includePersonalItems={includePersonalItems} items={filteredItems} + canDismiss={initialFilter === "all"} onDeleteDraft={handleDeleteDraft} + onDismiss={dismissInboxItem} + onDismissAll={dismissAllInboxItems} onFilterChange={handleFilterChange} onMarkRead={markItemRead} onMarkUnread={markItemUnread} @@ -752,7 +805,7 @@ export function HomeView({ item={selectedItem} latchedDefaultParentId={latchedDefaultParentId} messages={contextMessages} - profiles={feedProfiles} + profiles={effectiveFeedProfiles} selectedEventId={selectedEventId} unreadBoundaryEventId={unreadBoundaryEventId} onBack={ @@ -819,15 +872,16 @@ export function HomeView({ authorLabel: currentPubkey ? resolveUserLabel({ currentPubkey, - profiles: feedProfiles, + profiles: effectiveFeedProfiles, pubkey: authorPubkey, }) : "You", authorPubkey, avatarUrl: - currentPubkey && feedProfiles - ? (feedProfiles[currentPubkey.trim().toLowerCase()] - ?.avatarUrl ?? null) + currentPubkey && effectiveFeedProfiles + ? (effectiveFeedProfiles[ + currentPubkey.trim().toLowerCase() + ]?.avatarUrl ?? null) : null, content, createdAt: result.createdAt, diff --git a/desktop/src/features/home/ui/InboxFilterMenu.tsx b/desktop/src/features/home/ui/InboxFilterMenu.tsx index 6fcc759731..dab7598400 100644 --- a/desktop/src/features/home/ui/InboxFilterMenu.tsx +++ b/desktop/src/features/home/ui/InboxFilterMenu.tsx @@ -16,6 +16,7 @@ const INBOX_FILTER_OPTIONS: Array<{ value: InboxFilter; }> = [ { value: "all", label: "All" }, + { value: "alerts", label: "Alerts" }, { value: "project", label: "Projects" }, { value: "mention", label: "Mentions" }, { value: "thread", label: "Threads" }, @@ -32,6 +33,7 @@ type InboxFilterMenuProps = { activeDraftCount: number; dueReminderCount: number; filter: InboxFilter; + includePersonalItems: boolean; onFilterChange: (value: InboxFilter) => void; reminderCount: number; }; @@ -40,12 +42,16 @@ export function InboxFilterMenu({ activeDraftCount, dueReminderCount, filter, + includePersonalItems, onFilterChange, reminderCount, }: InboxFilterMenuProps) { - const activeFilter = INBOX_FILTER_OPTIONS.find( - (option) => option.value === filter, - ); + const filterOptions = includePersonalItems + ? INBOX_FILTER_OPTIONS + : INBOX_FILTER_OPTIONS.filter( + (option) => option.value !== "reminders" && option.value !== "drafts", + ); + const activeFilter = filterOptions.find((option) => option.value === filter); const statusLabel = dueReminderCount > 0 ? `${dueReminderCount} due reminder${dueReminderCount === 1 ? "" : "s"}` @@ -71,7 +77,7 @@ export function InboxFilterMenu({ onValueChange={(value) => onFilterChange(value as InboxFilter)} value={filter} > - {INBOX_FILTER_OPTIONS.map((option) => ( + {filterOptions.map((option) => (
{option.value === "reminders" ? ( diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index fa214dc730..12aba002c6 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,4 +1,4 @@ -import { Bell, Clock, Ellipsis, ExternalLink, MailOpen } from "lucide-react"; +import { Bell, Clock, Ellipsis, ExternalLink, MailOpen, X } from "lucide-react"; import * as React from "react"; import { @@ -44,6 +44,7 @@ import { VirtualizedList } from "@/shared/ui/VirtualizedList"; const INBOX_EMPTY_STATE_TITLES: Record = { all: "No activity yet", + alerts: "No alerts found", project: "No project work found", mention: "No mentions found", thread: "No threads found", @@ -55,6 +56,7 @@ const INBOX_EMPTY_STATE_TITLES: Record = { const INBOX_UNREAD_EMPTY_STATE_TITLES: Record = { all: "No unread activity", + alerts: "No unread alerts", project: "No unread project work", mention: "No unread mentions", thread: "No unread threads", @@ -175,10 +177,14 @@ type InboxListPaneProps = { activeReminderEventIds?: ReadonlySet; agentPubkeys?: ReadonlySet; activeDraftCount: number; + canDismiss: boolean; draftItems: DraftViewItem[]; doneSet: ReadonlySet; filter: InboxFilter; + includePersonalItems: boolean; items: InboxItem[]; + onDismiss: (itemId: string) => void; + onDismissAll: () => void; onFilterChange: (filter: InboxFilter) => void; onDeleteDraft: (draftKey: string) => void; onMarkRead: (itemId: string) => void; @@ -203,10 +209,14 @@ export function InboxListPane({ activeReminderEventIds, agentPubkeys, activeDraftCount, + canDismiss, draftItems, doneSet, filter, + includePersonalItems, items, + onDismiss, + onDismissAll, onFilterChange, onDeleteDraft, onMarkRead, @@ -234,13 +244,14 @@ export function InboxListPane({ () => buildInboxListRows({ items, - reminders: unreadOnly - ? [] - : reminders.filter((reminder) => - isDue(reminder, Math.floor(Date.now() / 1_000)), - ), + reminders: + !includePersonalItems || unreadOnly + ? [] + : reminders.filter((reminder) => + isDue(reminder, Math.floor(Date.now() / 1_000)), + ), }), - [items, reminders, unreadOnly], + [includePersonalItems, items, reminders, unreadOnly], ); const visibleInboxRows = React.useMemo( () => @@ -434,6 +445,14 @@ export function InboxListPane({ )} + {canDismiss ? ( + onDismiss(item.id)} + > + + + ) : null} )} + {canDismiss ? ( + <> + + onDismiss(item.id)}> + + Dismiss from inbox + + + ) : null} ) : null} + {canDismiss ? ( + + ) : null}
@@ -568,6 +612,7 @@ export function InboxListPane({ activeDraftCount={activeDraftCount} dueReminderCount={dueReminderCount} filter={filter} + includePersonalItems={includePersonalItems} onFilterChange={onFilterChange} reminderCount={reminders.length} /> diff --git a/desktop/src/features/messages/lib/agentMessageProjection.test.mjs b/desktop/src/features/messages/lib/agentMessageProjection.test.mjs new file mode 100644 index 0000000000..b714e95910 --- /dev/null +++ b/desktop/src/features/messages/lib/agentMessageProjection.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { projectAgentMessage } from "./agentMessageProjection.ts"; + +test("ordinary agent messages remain unchanged", () => { + const content = "I checked the build. Two tests failed in the auth crate."; + assert.deepEqual(projectAgentMessage(content), { + content, + rawDetailsHidden: false, + }); +}); + +test("automation payloads become a readable alert summary", () => { + const raw = `Actionable alert routed to Sylars Kind: ci-autofix Severity: error Source job: ci-autofix You may diagnose and edit scoped code. +Alert findings: [{"action":"issue","url":"https://github.com/acme/api/issues/92","modelUsage":{"tokens":199}}] +Report: Scanned 36 repo(s), handled 3 new CI failure(s) (mode: issue). +- Issue for acme/api (large) — https://github.com/acme/api/issues/92 Cannot be determined with confidence because the CI payload has no usable diagnostics. ${"x".repeat(800)} +- Deferred acme/portal until the model provider is available: provider 500: {"session_id":"abc","total_cost_usd":1.2}`; + const projected = projectAgentMessage(raw); + + assert.equal(projected.rawDetailsHidden, true); + assert.match(projected.content, /Actionable alert routed to Sylars/); + assert.match(projected.content, /Scanned 36 repositories/); + assert.match(projected.content, /usable CI diagnostics were unavailable/); + assert.match( + projected.content, + /deferred while the model provider is unavailable/, + ); + assert.doesNotMatch( + projected.content, + /session_id|total_cost_usd|modelUsage/, + ); +}); + +test("unknown machine payloads keep a human prefix and hide technical data", () => { + const raw = `Deployment check finished with warnings. You may diagnose the affected service.\n${'{\\"session_id\\":\\"abc\\"}'.repeat(80)}`; + const projected = projectAgentMessage(raw); + + assert.equal(projected.rawDetailsHidden, true); + assert.match(projected.content, /^Deployment check finished with warnings\./); + assert.match(projected.content, /Technical payload hidden/); +}); diff --git a/desktop/src/features/messages/lib/agentMessageProjection.ts b/desktop/src/features/messages/lib/agentMessageProjection.ts new file mode 100644 index 0000000000..fd8f523e49 --- /dev/null +++ b/desktop/src/features/messages/lib/agentMessageProjection.ts @@ -0,0 +1,127 @@ +export type AgentMessageProjection = { + content: string; + rawDetailsHidden: boolean; +}; + +const MACHINE_PAYLOAD_MARKERS = [ + /<\/?UNTRUSTED_[A-Z_]+>/i, + /\bprovider\s+\d{3}\s*:\s*\{/i, + /\\"(?:content_filter|modelUsage|session_id|total_cost_usd)\\"/i, + /"(?:content_filter|modelUsage|session_id|total_cost_usd)"\s*:/i, + /\bAlert findings:\s*\[/i, +]; + +function compactWhitespace(value: string) { + return value.replace(/\s+/g, " ").trim(); +} + +function clipped(value: string, maxLength = 220) { + const compact = compactWhitespace(value); + if (compact.length <= maxLength) return compact; + const candidate = compact.slice(0, maxLength); + const boundary = candidate.lastIndexOf(" "); + return `${candidate.slice(0, Math.max(boundary, maxLength - 30)).trim()}…`; +} + +function alertHeading(content: string) { + const match = content.match( + /Actionable alert routed to\s+(.+?)\s+Kind:\s*([^\s]+)\s+Severity:\s*([^\s]+)\s+Source(?:\s+job)?:\s*([^\s]+)/i, + ); + if (!match) return null; + const [, recipient, kind, severity, source] = match; + return [ + `**Actionable alert routed to ${compactWhitespace(recipient ?? "the assigned agent")}**`, + `- ${kind ?? "Alert"} · ${severity ?? "unknown"} severity · source: ${source ?? "unknown"}`, + ]; +} + +function reportSummary(content: string) { + const match = content.match( + /Report:\s*Scanned\s+(\d+)\s+repo\(s\),\s*handled\s+(\d+)\s+new\s+CI\s+failure\(s\)(?:\s*\(mode:\s*([^)]+)\))?/i, + ); + if (!match) return null; + const [, scanned, handled, mode] = match; + return `- Scanned ${scanned} repositories and handled ${handled} new CI failures${mode ? ` in ${mode} mode` : ""}.`; +} + +function issueSummaries(content: string) { + const results: string[] = []; + const issuePattern = + /Issue for\s+([^\s]+)(?:\s+\([^)]+\))?\s+[—-]\s+(https?:\/\/[^\s]+)\s+([\s\S]*?)(?=(?:\n\s*[-*•]?\s*)?(?:Issue for|Deferred\s+[^\s]+\s+until)|$)/gi; + for (const match of content.matchAll(issuePattern)) { + const repo = match[1] ?? "Repository"; + const url = (match[2] ?? "").replace(/[),.;]+$/, ""); + const detail = match[3] ?? ""; + let summary: string; + if ( + /no usable diagnostics|cannot be determined|unknown with certainty/i.test( + detail, + ) + ) { + summary = + "Diagnosis was inconclusive because usable CI diagnostics were unavailable."; + } else { + summary = clipped(detail.split(/(?<=[.!?])\s/)[0] ?? detail, 180); + } + results.push( + `- [${repo}](${url}) — ${summary || "Issue created for review."}`, + ); + if (results.length === 6) break; + } + return results; +} + +function deferredSummaries(content: string) { + const results: string[] = []; + const deferredPattern = + /Deferred\s+([^\s]+)\s+until the model provider is available/gi; + for (const match of content.matchAll(deferredPattern)) { + results.push( + `- ${match[1] ?? "A repository"} — deferred while the model provider is unavailable; it can retry later.`, + ); + if (results.length === 6) break; + } + return results; +} + +function genericProjection(content: string) { + const firstMarker = MACHINE_PAYLOAD_MARKERS.reduce((earliest, marker) => { + const index = content.search(marker); + return index >= 0 && (earliest < 0 || index < earliest) ? index : earliest; + }, -1); + const humanPrefix = content.slice(0, firstMarker < 0 ? 360 : firstMarker); + const firstParagraph = humanPrefix + .split(/\n\s*\n|\n(?=[-*•]\s)/)[0] + ?.replace( + /\b(?:After explicit Buzz approval|You may diagnose)[\s\S]*$/i, + "", + ) + .trim(); + return `${clipped(firstParagraph || "Agent update", 320)}\n\n_Technical payload hidden. Open raw details if you need it._`; +} + +/** + * Projects machine-heavy agent output into readable channel copy. The original + * remains available behind an explicit disclosure in MessageRow. + */ +export function projectAgentMessage(content: string): AgentMessageProjection { + const hasMachinePayload = MACHINE_PAYLOAD_MARKERS.some((marker) => + marker.test(content), + ); + const hasOversizedLine = content + .split(/\r?\n/) + .some((line) => line.length > 700); + if (!hasMachinePayload || (!hasOversizedLine && content.length < 1_200)) { + return { content, rawDetailsHidden: false }; + } + + const lines = alertHeading(content) ?? []; + const report = reportSummary(content); + if (report) lines.push(report); + lines.push(...issueSummaries(content), ...deferredSummaries(content)); + + return { + content: lines.length > 0 ? lines.join("\n") : genericProjection(content), + rawDetailsHidden: true, + }; +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..f625089655 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -16,9 +16,11 @@ import { coalesceAutocompleteCandidatesByKey, getMentionableAgentPubkeys, getSharedChannelIds, - isAgentIdentityInManagedList, + isAgentIdentityInKnownDirectories, + resolveAgentMentionDisplayName, shouldHideAgentFromMentions, } from "@/features/agents/lib/agentAutocompleteEligibility"; +import { localRosterForHostedCommunity } from "@/features/agents/lib/hostedAgentView"; import { useInfiniteUserSearchQuery, useUsersBatchQuery, @@ -221,10 +223,15 @@ export function useMentions( return lookup; }, [managedAgentsQuery.data, personasQuery.data]); const knownAgentPubkeys = mentionableAgentPubkeys; - const activePersonas = React.useMemo( - () => (personasQuery.data ?? []).filter((persona) => persona.isActive), - [personasQuery.data], - ); + const hasHostedAgents = (relayAgentsQuery.data?.length ?? 0) > 0; + const activePersonas = React.useMemo(() => { + const localRoster = localRosterForHostedCommunity( + personasQuery.data ?? [], + [], + hasHostedAgents, + ); + return localRoster.personas.filter((persona) => persona.isActive); + }, [hasHostedAgents, personasQuery.data]); const activePersonaById = React.useMemo( () => new Map(activePersonas.map((persona) => [persona.id, persona])), [activePersonas], @@ -246,7 +253,13 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { + if ( + !isAgentIdentityInKnownDirectories( + candidate, + managedAgentPubkeys, + directoryAgentPubkeys, + ) + ) { return; } if ( @@ -305,12 +318,12 @@ export function useMentions( addCandidate({ kind: "identity", pubkey, - displayName: - member.displayName?.trim() || - agentName || - profile?.displayName?.trim() || - profile?.nip05Handle?.trim() || - null, + displayName: resolveAgentMentionDisplayName({ + directoryName: agentName, + memberName: member.displayName, + profileDisplayName: profile?.displayName, + profileHandle: profile?.nip05Handle, + }), avatarUrl: profile?.avatarUrl ?? null, isMember: true, personaId: @@ -330,18 +343,18 @@ export function useMentions( : null, }); } - for (const agent of relayAgentsQuery.data ?? []) { const pubkey = normalizePubkey(agent.pubkey); addCandidate({ kind: "identity", pubkey, displayName: agent.name, + avatarUrl: agent.avatarUrl ?? null, isMember: false, personaId: managedAgentPersonaIdsByPubkey.get(pubkey) ?? (activePersonaById.has(pubkey) ? pubkey : undefined), - ownerPubkey: null, + ownerPubkey: agent.ownerPubkey ?? null, isAgent: true, }); } @@ -386,17 +399,19 @@ export function useMentions( } } - const personaCandidates: MentionCandidate[] = activePersonas - .filter((persona) => !managedAgentPersonaIds.has(persona.id)) - .map((persona) => ({ - kind: "persona" as const, - personaId: persona.id, - displayName: persona.displayName, - avatarUrl: persona.avatarUrl, - isMember: false, - isAgent: true, - })) - .filter((candidate) => candidate.displayName.trim().length > 0); + const personaCandidates: MentionCandidate[] = hasHostedAgents + ? [] + : activePersonas + .filter((persona) => !managedAgentPersonaIds.has(persona.id)) + .map((persona) => ({ + kind: "persona" as const, + personaId: persona.id, + displayName: persona.displayName, + avatarUrl: persona.avatarUrl, + isMember: false, + isAgent: true, + })) + .filter((candidate) => candidate.displayName.trim().length > 0); return coalesceAgentAutocompleteCandidates( coalesceAutocompleteCandidatesByKey( @@ -416,6 +431,7 @@ export function useMentions( canSearchGlobalUsers, currentPubkey, directoryAgentPubkeys, + hasHostedAgents, isArchivedDiscovery, managedAgentNamesByPubkey, managedAgentPersonaIds, @@ -497,7 +513,35 @@ export function useMentions( const names: string[] = []; const seen = new Set(); - for (const name of selectedAgentMentionNames) { + const agentNameCounts = new Map(); + for (const candidate of mentionCandidates) { + if ( + candidate.isAgent !== true || + !candidate.pubkey || + !knownAgentPubkeys.has(normalizePubkey(candidate.pubkey)) + ) { + continue; + } + const name = candidate.displayName?.trim().toLowerCase(); + if (name) { + agentNameCounts.set(name, (agentNameCounts.get(name) ?? 0) + 1); + } + } + + for (const name of [ + ...selectedAgentMentionNames, + ...mentionCandidates + .filter( + (candidate) => + candidate.isAgent === true && + Boolean(candidate.pubkey) && + knownAgentPubkeys.has(normalizePubkey(candidate.pubkey ?? "")) && + agentNameCounts.get( + candidate.displayName?.trim().toLowerCase() ?? "", + ) === 1, + ) + .map((candidate) => candidate.displayName ?? ""), + ]) { const trimmed = name.trim(); if (trimmed && !seen.has(trimmed.toLowerCase())) { names.push(trimmed); @@ -506,7 +550,7 @@ export function useMentions( } return names; - }, [selectedAgentMentionNames]); + }, [knownAgentPubkeys, mentionCandidates, selectedAgentMentionNames]); const searchableNamesLower = React.useMemo( () => searchableNames.map((n) => n.toLowerCase()), @@ -750,8 +794,14 @@ export function useMentions( ); const isAgentPubkey = React.useCallback( - (pubkey: string): boolean => knownAgentPubkeys.has(normalizePubkey(pubkey)), - [knownAgentPubkeys], + (pubkey: string): boolean => { + const normalized = normalizePubkey(pubkey); + return ( + knownAgentPubkeys.has(normalized) || + directoryAgentPubkeys.has(normalized) + ); + }, + [directoryAgentPubkeys, knownAgentPubkeys], ); const isManagedAgentPubkey = React.useCallback( (pubkey: string): boolean => @@ -794,6 +844,20 @@ export function useMentions( const extractMentionPubkeys = React.useCallback( (text: string): string[] => { const pubkeys: string[] = []; + const directNameCounts = new Map(); + for (const candidate of mentionCandidates) { + if ( + !candidate.displayName || + !candidate.pubkey || + (candidate.isMember !== true && + (candidate.isAgent !== true || + !knownAgentPubkeys.has(normalizePubkey(candidate.pubkey)))) + ) { + continue; + } + const name = candidate.displayName.trim().toLowerCase(); + directNameCounts.set(name, (directNameCounts.get(name) ?? 0) + 1); + } const selectedDisplayNames = new Set( [ ...mentionMapRef.current.keys(), @@ -811,7 +875,13 @@ export function useMentions( if (!candidate.pubkey) { continue; } - if (!candidate.isMember) { + const normalizedName = candidate.displayName?.trim().toLowerCase(); + const isUniqueAuthorizedAgent = + candidate.isAgent === true && + knownAgentPubkeys.has(normalizePubkey(candidate.pubkey)) && + Boolean(normalizedName) && + directNameCounts.get(normalizedName ?? "") === 1; + if (!candidate.isMember && !isUniqueAuthorizedAgent) { continue; } if (pubkeys.includes(candidate.pubkey)) { @@ -828,7 +898,23 @@ export function useMentions( return [...new Set(pubkeys)]; }, - [mentionCandidates], + [knownAgentPubkeys, mentionCandidates], + ); + const extractMentionAgentPubkeys = React.useCallback( + (text: string): string[] => { + const resolvedPubkeys = new Set( + extractMentionPubkeys(text).map(normalizePubkey), + ); + return mentionCandidates + .filter( + (candidate) => + candidate.isAgent === true && + Boolean(candidate.pubkey) && + resolvedPubkeys.has(normalizePubkey(candidate.pubkey ?? "")), + ) + .map((candidate) => candidate.pubkey as string); + }, + [extractMentionPubkeys, mentionCandidates], ); const extractMentionPersonas = React.useCallback( @@ -972,6 +1058,7 @@ export function useMentions( cancelMentionAutocomplete, clearMentions, extractMentionPersonas, + extractMentionAgentPubkeys, extractMentionPubkeys, getDraftMentionRefs, getMentionDisplayName, diff --git a/desktop/src/features/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx index 2250b3931f..cbcb873f5b 100644 --- a/desktop/src/features/messages/ui/MessageReactions.tsx +++ b/desktop/src/features/messages/ui/MessageReactions.tsx @@ -126,7 +126,10 @@ function ReactionPopoverContent({ reaction }: { reaction: TimelineReaction }) {
{userText} reacted with
-
+
{displayName}
@@ -504,7 +507,7 @@ function ReactionPill({ align="start" side="top" sideOffset={6} - className="w-auto min-w-56 max-w-72 rounded-xl p-3" + className="w-72 rounded-xl p-3" onMouseEnter={handleMouseEnter} onMouseLeave={scheduleClose} onOpenAutoFocus={(e) => e.preventDefault()} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 286526b658..49ab6bbaea 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -36,6 +36,7 @@ import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext" import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji"; import { parseWaveMessageContent } from "@/features/messages/lib/waveMessage"; +import { projectAgentMessage } from "@/features/messages/lib/agentMessageProjection"; import { resolveSnapshotSharedBy } from "@/features/messages/lib/snapshotSharedBy"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { Markdown } from "@/shared/ui/markdown"; @@ -209,6 +210,16 @@ export const MessageRow = React.memo( (message.pubkey && isKnownAgentPubkey(message.pubkey)) ? "bot" : message.role; + const authorIsAgent = Boolean( + message.pubkey && isKnownAgentPubkey(message.pubkey), + ); + const projectedBody = React.useMemo( + () => + authorIsAgent + ? projectAgentMessage(message.body) + : { content: message.body, rawDetailsHidden: false }, + [authorIsAgent, message.body], + ); const agentMentionPubkeysByName = React.useMemo(() => { if (!mentionPubkeysByName) { return undefined; @@ -356,31 +367,43 @@ export const MessageRow = React.memo( } return ( - + <> + + {projectedBody.rawDetailsHidden ? ( +
+ + Show raw details + +
+                    {message.body}
+                  
+
+ ) : null} + ); } }; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 6d4e007cd4..7f36caf261 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -699,7 +699,10 @@ export function useMentionSendFlow({ ]); const explicitAgentPubkeys = explicitMentionPubkeys.filter( (pubkey) => - mentions.isAgentPubkey(pubkey) || + mentions + .extractMentionAgentPubkeys(trimmed) + .map(normalizePubkey) + .includes(normalizePubkey(pubkey)) || createdPersonaAgentPubkeySet.has(pubkey), ); const pubkeys = explicitMentionPubkeys; @@ -731,6 +734,33 @@ export function useMentionSendFlow({ } } + const explicitAgentPubkeySet = new Set( + explicitAgentPubkeys.map(normalizePubkey), + ); + const mentionedRelayAgentPubkeys = promptNonMemberPubkeys.filter( + (pubkey) => explicitAgentPubkeySet.has(normalizePubkey(pubkey)), + ); + // The relay is authoritative for channel moderation. Attempt the + // agent add directly so an owner/admin send does not race a separate + // role lookup; permission failures fall through to the existing + // confirmation dialog without publishing the message. + const autoInviteAgentPubkeys = mentionedRelayAgentPubkeys; + if (autoInviteAgentPubkeys.length > 0) { + const result = await addMembersMutation.mutateAsync({ + channelId: effectiveChannelId ?? undefined, + pubkeys: autoInviteAgentPubkeys, + role: "bot", + }); + if (result.errors.length === 0) { + const invitedAgentPubkeys = new Set( + autoInviteAgentPubkeys.map(normalizePubkey), + ); + promptNonMemberPubkeys = promptNonMemberPubkeys.filter( + (pubkey) => !invitedAgentPubkeys.has(normalizePubkey(pubkey)), + ); + } + } + const pendingDraft: PendingNonMemberMentionSend = { capturedChannelId: effectiveChannelId, capturedThreadContext, @@ -765,6 +795,7 @@ export function useMentionSendFlow({ } }, [ + addMembersMutation, completeSend, channelType, createMentionedPersonaAgents, @@ -773,7 +804,7 @@ export function useMentionSendFlow({ getNonMemberMentionPubkeys, getDmThreadAgentMentionError, mentions.extractMentionPubkeys, - mentions.isAgentPubkey, + mentions.extractMentionAgentPubkeys, mentions.isManagedAgentPubkey, onPrepareSendChannel, ], diff --git a/desktop/src/features/notifications/hooks.test.mjs b/desktop/src/features/notifications/hooks.test.mjs index e5b90f3acc..92f71f103a 100644 --- a/desktop/src/features/notifications/hooks.test.mjs +++ b/desktop/src/features/notifications/hooks.test.mjs @@ -37,11 +37,15 @@ const homeFeed = (feed) => ({ meta: { since: 0, total: 0, generatedAt: 0 }, }); -test("home badge items include locally unread activity and agent rows", () => { +test("home badge items include only explicit approval requests", () => { + const approval = (id) => ({ ...feedItem(id, "needs_action"), kind: 46010 }); const items = buildHomeBadgeFeedItems( homeFeed({ mentions: [feedItem("mention", "mention")], - needsAction: [feedItem("needs-action", "needs_action")], + needsAction: [ + approval("approval"), + feedItem("other-needs-action", "needs_action"), + ], activity: [ feedItem("locally-unread-activity"), feedItem("read-activity"), @@ -51,19 +55,12 @@ test("home badge items include locally unread activity and agent rows", () => { feedItem("read-agent", "agent_activity"), ], }), - [feedItem("thread-activity")], - new Set(["locally-unread-activity", "locally-unread-agent"]), + [feedItem("thread-activity"), approval("extra-approval")], ); assert.deepEqual( items.map((item) => item.id), - [ - "mention", - "needs-action", - "thread-activity", - "locally-unread-activity", - "locally-unread-agent", - ], + ["approval", "extra-approval"], ); }); diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index ccc9544f94..b78404a14f 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -392,8 +392,8 @@ export function useHomeFeedNotificationState( readStoredSeenFeedIds(normalizedPubkey), ); const currentFeedItems = React.useMemo(() => { - return buildHomeBadgeFeedItems(feed, extraInboxItems, localUnreadFeedIds); - }, [extraInboxItems, feed, localUnreadFeedIds]); + return buildHomeBadgeFeedItems(feed, extraInboxItems); + }, [extraInboxItems, feed]); const currentFeedIds = React.useMemo( () => currentFeedItems.map((item) => item.id), [currentFeedItems], diff --git a/desktop/src/features/notifications/lib/homeBadge.ts b/desktop/src/features/notifications/lib/homeBadge.ts index deac9b73a5..4e3d578d18 100644 --- a/desktop/src/features/notifications/lib/homeBadge.ts +++ b/desktop/src/features/notifications/lib/homeBadge.ts @@ -5,6 +5,7 @@ import { isBroadcastReply, isThreadReply, } from "@/features/messages/lib/threading"; +import { KIND_APPROVAL_REQUEST } from "@/shared/constants/kinds"; function dedupeFeedItemsById(items: readonly FeedItem[]): FeedItem[] { const seen = new Set(); @@ -22,22 +23,14 @@ function dedupeFeedItemsById(items: readonly FeedItem[]): FeedItem[] { export function buildHomeBadgeFeedItems( feed: HomeFeedResponse | undefined, extraInboxItems: readonly FeedItem[], - localUnreadFeedIds: ReadonlySet, ): FeedItem[] { const items = feed - ? [...feed.feed.mentions, ...feed.feed.needsAction, ...extraInboxItems] + ? [...feed.feed.needsAction, ...extraInboxItems] : [...extraInboxItems]; - if (feed && localUnreadFeedIds.size > 0) { - items.push( - ...feed.feed.activity.filter((item) => localUnreadFeedIds.has(item.id)), - ...feed.feed.agentActivity.filter((item) => - localUnreadFeedIds.has(item.id), - ), - ); - } - - return dedupeFeedItemsById(items); + return dedupeFeedItemsById( + items.filter((item) => item.kind === KIND_APPROVAL_REQUEST), + ); } export function shouldCountTowardHomeBadgeSubtotal( diff --git a/desktop/src/features/profile/ui/UserProfileAgentActions.tsx b/desktop/src/features/profile/ui/UserProfileAgentActions.tsx index 81db3405b2..d821e9abc8 100644 --- a/desktop/src/features/profile/ui/UserProfileAgentActions.tsx +++ b/desktop/src/features/profile/ui/UserProfileAgentActions.tsx @@ -4,6 +4,7 @@ import { ArchiveRestore, CopyPlus, Download, + Pencil, Power, Settings, Trash2, @@ -39,6 +40,8 @@ export function UserProfileAgentSettingsMenu({ managedAgent, onDelete, onDuplicatePersona, + onEditAgent, + onEditHostedAgent, onExportPersona, onToggleAutoStart, personaActionKey, @@ -49,6 +52,8 @@ export function UserProfileAgentSettingsMenu({ managedAgent?: ManagedAgent; onDelete?: () => void; onDuplicatePersona?: () => void; + onEditAgent?: () => void; + onEditHostedAgent?: () => void; onExportPersona?: () => void; onToggleAutoStart?: () => void; personaActionKey?: string; @@ -62,7 +67,9 @@ export function UserProfileAgentSettingsMenu({ managedAgent.backend.type === "local" && onToggleAutoStart !== undefined; const autoStartSwitchId = `user-profile-agent-auto-start-${actionKey}`; - const hasPrimaryActions = Boolean(onDuplicatePersona || onExportPersona); + const hasPrimaryActions = Boolean( + onEditAgent || onEditHostedAgent || onDuplicatePersona || onExportPersona, + ); const hasArchiveAction = archiveActions?.canArchive === true && archiveActions.isArchived !== undefined; @@ -98,6 +105,16 @@ export function UserProfileAgentSettingsMenu({ className="min-w-56" onCloseAutoFocus={(event) => event.preventDefault()} > + {onEditAgent || onEditHostedAgent ? ( + + + Edit agent + + ) : null} {canToggleAutoStart ? ( void; onDeletePersona: () => void; onDuplicatePersona: () => void; + onEditAgent?: () => void; + onEditHostedAgent?: () => void; onExportPersona: () => void; onToggleAutoStart: () => void; personaActionKey?: string; @@ -254,6 +275,7 @@ export function UserProfileAgentSettingsMenuSlot({ isBot, isPending: settingsActionPending, onDuplicatePersona: canManagePersona ? onDuplicatePersona : undefined, + onEditHostedAgent, onExportPersona: canManagePersona ? onExportPersona : undefined, personaActionKey, }; @@ -264,6 +286,7 @@ export function UserProfileAgentSettingsMenuSlot({ {...sharedProps} managedAgent={managedAgent} onDelete={onDeleteAgent} + onEditAgent={onEditAgent} onToggleAutoStart={onToggleAutoStart} /> ); @@ -278,12 +301,13 @@ export function UserProfileAgentSettingsMenuSlot({ ); } - if (canShowArchiveAction) { + if (canShowArchiveAction || onEditHostedAgent) { return ( ); } diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index af30728d6a..95a4530568 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -34,6 +34,9 @@ import { } from "@/features/agents/lib/instanceInputForDefinition"; import { describeLogFile } from "@/features/agents/ui/agentUi"; import { AgentDialog } from "@/features/agents/ui/AgentDialog"; +import { HostedAgentEditDialog } from "@/features/agents/ui/HostedAgentEditDialog"; +import { getHostedAgentPresentation } from "@/features/agents/lib/hostedAgentPresentation"; +import { useMyRelayMembershipQuery } from "@/features/community-members/hooks"; import { useAgentLifecycleActions } from "@/features/profile/ui/useAgentLifecycleActions"; import { consumePendingOpenEditAgent, @@ -270,13 +273,11 @@ export function UserProfilePanel({ const unfollowMutation = useUnfollowMutation(currentPubkey); const { canOpenAgentActivity, openAgentActivity } = useOpenAgentActivity(); const { goChannel } = useAppNavigation(); - const profile = resolvePanelProfile({ + const baseProfile = resolvePanelProfile({ managedAgent, persona: resolvedPersona, profile: profileQuery.data, }); - const ownerPubkey = profile?.ownerPubkey ?? null; - const ownerProfileQuery = useUserProfileQuery(ownerPubkey ?? undefined); const presenceStatus = pubkeyLower ? presenceQuery.data?.[pubkeyLower] : undefined; @@ -287,6 +288,23 @@ export function UserProfilePanel({ const relayAgent = relayAgentsQuery.data?.find( (agent) => agent.pubkey.toLowerCase() === pubkeyLower, ); + const hostedPresentation = relayAgent + ? getHostedAgentPresentation(relayAgent, baseProfile) + : null; + const profile = relayAgent + ? { + pubkey: relayAgent.pubkey, + displayName: hostedPresentation?.displayName ?? null, + avatarUrl: hostedPresentation?.avatarUrl ?? null, + about: baseProfile?.about ?? null, + nip05Handle: baseProfile?.nip05Handle ?? null, + ownerPubkey: baseProfile?.ownerPubkey ?? relayAgent.ownerPubkey ?? null, + hasProfileEvent: baseProfile?.hasProfileEvent ?? false, + } + : baseProfile; + const ownerPubkey = profile?.ownerPubkey ?? relayAgent?.ownerPubkey ?? null; + const ownerProfileQuery = useUserProfileQuery(ownerPubkey ?? undefined); + const myRelayMembershipQuery = useMyRelayMembershipQuery(); const managedAgentLogQuery = useManagedAgentLogQuery( (view === "diagnostics" || view === "logs") && managedAgent?.backend.type === "local" @@ -331,6 +349,12 @@ export function UserProfilePanel({ const canEditAgent = isOwner === true && (managedAgent !== undefined || resolvedPersona !== undefined); + const membershipRole = myRelayMembershipQuery.data?.role; + const canEditHostedAgent = + relayAgent !== undefined && + (membershipRole === "owner" || + membershipRole === "admin" || + relayAgent.ownerPubkey?.toLowerCase() === currentPubkey?.toLowerCase()); const memoryQuery = useAgentMemoryQuery(effectivePubkey, { enabled: viewerIsOwner && Boolean(effectivePubkey), }); @@ -368,14 +392,8 @@ export function UserProfilePanel({ false); const profileChannels = React.useMemo( - () => - deriveProfileChannels( - pubkeyLower, - relayAgent, - managedAgent, - channelsQuery.data, - ), - [pubkeyLower, relayAgent, managedAgent, channelsQuery.data], + () => deriveProfileChannels(pubkeyLower, relayAgent, channelsQuery.data), + [pubkeyLower, relayAgent, channelsQuery.data], ); const channelIdToName = React.useMemo(() => { @@ -733,6 +751,8 @@ export function UserProfilePanel({ onDeleteAgent={handleDeleteAgent} onDeletePersona={handleDeletePersona} onDuplicatePersona={handleDuplicatePersona} + onEditAgent={canEditAgent ? handleEditAgent : undefined} + onEditHostedAgent={canEditHostedAgent ? handleEditAgent : undefined} onExportPersona={handleExportPersona} onToggleAutoStart={handleToggleAgentAutoStart} personaActionKey={resolvedPersona?.id} @@ -906,7 +926,17 @@ export function UserProfilePanel({ ); const editAgentDialog = - canEditAgent && managedAgent ? ( + canEditHostedAgent && relayAgent && !managedAgent ? ( + { + await relayAgentsQuery.refetch(); + await profileQuery.refetch(); + }} + open={editAgentOpen} + /> + ) : canEditAgent && managedAgent ? ( { + const pubkey = "ab".repeat(32); + const channels = [ + { + id: "operations-id", + name: "operations", + memberPubkeys: [pubkey.toUpperCase()], + }, + { id: "general-id", name: "general", memberPubkeys: [] }, + ]; + const hostedAgent = { + pubkey, + channels: [], + channelIds: [], + }; + + assert.deepEqual(deriveProfileChannels(pubkey, hostedAgent, channels), [ + { id: "operations-id", name: "operations" }, + ]); +}); + function agent(overrides = {}) { return { pubkey: "deadbeef".repeat(8), diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 07f57803b4..59159f981d 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -116,7 +116,6 @@ export type UserProfilePanelProps = { export function deriveProfileChannels( pubkeyLower: string, relayAgent: RelayAgent | undefined, - managedAgent: ManagedAgent | undefined, channels: Channel[] | undefined, ): ProfileChannelLink[] { const links = new Map(); @@ -130,7 +129,7 @@ export function deriveProfileChannels( links.set(id, { id, name }); }); - if (managedAgent && channels) { + if (pubkeyLower && channels) { for (const channel of channels) { const isMember = channel.memberPubkeys.some( (memberPubkey) => memberPubkey.toLowerCase() === pubkeyLower, diff --git a/desktop/src/features/search/useSearchResults.ts b/desktop/src/features/search/useSearchResults.ts index b31d69a954..1b9966036f 100644 --- a/desktop/src/features/search/useSearchResults.ts +++ b/desktop/src/features/search/useSearchResults.ts @@ -4,6 +4,7 @@ import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; +import { relayAgentIsSharedWithUser } from "@/features/agents/lib/agentAutocompleteEligibility"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserSearchQuery, @@ -24,6 +25,7 @@ import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; export const MIN_SEARCH_QUERY_LENGTH = 2; +const NO_SHARED_CHANNELS: ReadonlySet = new Set(); function formatUserResultName(user: UserSearchResult) { return user.displayName?.trim() || user.nip05Handle?.trim() || user.pubkey; @@ -288,7 +290,7 @@ export function useSearchResults({ const pubkeys = new Set(managedAgentPubkeys); for (const agent of relayAgentsQuery.data ?? []) { - if (agent.respondTo === "anyone") { + if (relayAgentIsSharedWithUser(agent, NO_SHARED_CHANNELS)) { pubkeys.add(normalizePubkey(agent.pubkey)); } } @@ -355,7 +357,7 @@ export function useSearchResults({ } for (const agent of relayAgentsQuery.data ?? []) { - if (agent.respondTo !== "anyone") { + if (!relayAgentIsSharedWithUser(agent, NO_SHARED_CHANNELS)) { continue; } diff --git a/desktop/src/features/sidebar/lib/defaultChannelGroups.test.mjs b/desktop/src/features/sidebar/lib/defaultChannelGroups.test.mjs new file mode 100644 index 0000000000..10a4916a2a --- /dev/null +++ b/desktop/src/features/sidebar/lib/defaultChannelGroups.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isProjectChannel, + partitionDefaultChannelGroups, +} from "./defaultChannelGroups.ts"; + +function channel(name) { + return { id: name, name }; +} + +test("uses the same named project channels as the web workspace", () => { + for (const name of [ + "aaral-pms", + "sylars-control", + "varvik-suite", + "zup-coffee", + ]) { + assert.equal(isProjectChannel(channel(name)), true, name); + } +}); + +test("recognizes future project-prefixed channels", () => { + assert.equal(isProjectChannel(channel("project-atlas")), true); + assert.equal(isProjectChannel(channel("PROJECT-ATLAS")), true); +}); + +test("keeps operational channels in Workspace", () => { + const result = partitionDefaultChannelGroups([ + channel("general"), + channel("watchdog-alerts"), + channel("varvik-suite"), + channel("project-atlas"), + ]); + + assert.deepEqual( + result.workspace.map((item) => item.name), + ["general", "watchdog-alerts"], + ); + assert.deepEqual( + result.projects.map((item) => item.name), + ["varvik-suite", "project-atlas"], + ); +}); diff --git a/desktop/src/features/sidebar/lib/defaultChannelGroups.ts b/desktop/src/features/sidebar/lib/defaultChannelGroups.ts new file mode 100644 index 0000000000..2553328c99 --- /dev/null +++ b/desktop/src/features/sidebar/lib/defaultChannelGroups.ts @@ -0,0 +1,42 @@ +import type { Channel } from "@/shared/api/types"; + +// Keep this list aligned with the VarVik workspace grouping used by the web +// client. The relay does not currently expose a first-class project category, +// so both clients derive the default section from the stable channel name. +export const PROJECT_CHANNEL_NAMES = new Set([ + "aaral-pms", + "ashrayu-media", + "atelier-crm", + "bidwave", + "factoryos", + "fzine", + "hrr-capital", + "nuve", + "project-dukaan", + "renderboard", + "sylars-control", + "ummidvar", + "vakeelos", + "varvik-suite", + "varvik-website", + "zup-coffee", +]); + +export function isProjectChannel(channel: Pick): boolean { + const name = channel.name.toLowerCase(); + return PROJECT_CHANNEL_NAMES.has(name) || name.startsWith("project-"); +} + +export function partitionDefaultChannelGroups(channels: Channel[]): { + workspace: Channel[]; + projects: Channel[]; +} { + const workspace: Channel[] = []; + const projects: Channel[] = []; + + for (const channel of channels) { + (isProjectChannel(channel) ? projects : workspace).push(channel); + } + + return { workspace, projects }; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 55f467f215..9590256e9b 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -23,6 +23,10 @@ import { useChannelSortPreference } from "@/features/sidebar/lib/useChannelSortP import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock"; import { isSidebarBackgroundTarget } from "@/features/sidebar/lib/sidebarBackgroundTarget"; import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; +import { + isProjectChannel, + partitionDefaultChannelGroups, +} from "@/features/sidebar/lib/defaultChannelGroups"; import { CreateSectionDialog, DeleteSectionAlertDialog, @@ -75,11 +79,47 @@ import { } from "@/shared/ui/sidebar"; type CollapsibleSidebarGroup = - | "starred" - | "channels" + | "favorites" + | "workspace" + | "projects" | "forums" | "directMessages"; +const DEFAULT_COLLAPSED_GROUPS: Record = { + favorites: false, + workspace: false, + projects: false, + forums: false, + directMessages: false, +}; + +const COLLAPSED_GROUPS_STORAGE_PREFIX = "buzz-sidebar-groups.v1"; + +function collapsedGroupsStorageKey(pubkey?: string, relayUrl?: string) { + return `${COLLAPSED_GROUPS_STORAGE_PREFIX}:${pubkey ?? "anonymous"}:${encodeURIComponent(relayUrl ?? "default")}`; +} + +function readCollapsedGroups(pubkey?: string, relayUrl?: string) { + try { + const raw = window.localStorage.getItem( + collapsedGroupsStorageKey(pubkey, relayUrl), + ); + if (!raw) return DEFAULT_COLLAPSED_GROUPS; + const parsed = JSON.parse(raw) as Partial< + Record + >; + return { + favorites: parsed.favorites === true, + workspace: parsed.workspace === true, + projects: parsed.projects === true, + forums: parsed.forums === true, + directMessages: parsed.directMessages === true, + }; + } catch { + return DEFAULT_COLLAPSED_GROUPS; + } +} + type CreateChannelKind = "stream" | "forum"; type AppSidebarProps = { @@ -100,6 +140,7 @@ type AppSidebarProps = { selectedChannelId: string | null; selectedView: | "home" + | "alerts" | "channel" | "messages" | "agents" @@ -143,6 +184,7 @@ type AppSidebarProps = { onRemoveCommunity: (id: string) => void; onCreateAgent: () => void; onSelectAgents: () => void; + onSelectAlerts: () => void; onSelectProjects: () => void; onSelectPulse: () => void; onSelectWorkflows: () => void; @@ -212,6 +254,7 @@ export function AppSidebar({ onRemoveCommunity, onCreateAgent, onSelectAgents, + onSelectAlerts, onSelectProjects, onSelectPulse, onSelectWorkflows, @@ -315,14 +358,37 @@ export function AppSidebar({ openCreateDialog("stream"); } }, [isCreateChannelOpenProp, openCreateDialog]); + const collapsedGroupsScope = collapsedGroupsStorageKey( + currentPubkey, + activeCommunity?.relayUrl, + ); const [collapsedGroups, setCollapsedGroups] = React.useState< Record - >({ - starred: false, - channels: false, - forums: false, - directMessages: false, - }); + >(() => readCollapsedGroups(currentPubkey, activeCommunity?.relayUrl)); + const loadedCollapsedGroupsScopeRef = React.useRef(collapsedGroupsScope); + + React.useEffect(() => { + if (loadedCollapsedGroupsScopeRef.current !== collapsedGroupsScope) { + loadedCollapsedGroupsScopeRef.current = collapsedGroupsScope; + setCollapsedGroups( + readCollapsedGroups(currentPubkey, activeCommunity?.relayUrl), + ); + return; + } + try { + window.localStorage.setItem( + collapsedGroupsScope, + JSON.stringify(collapsedGroups), + ); + } catch { + // Collapsing still works for the current session when storage is blocked. + } + }, [ + activeCommunity?.relayUrl, + collapsedGroups, + collapsedGroupsScope, + currentPubkey, + ]); const toggleCollapsedGroup = React.useCallback( (group: CollapsibleSidebarGroup) => { @@ -413,9 +479,17 @@ export function AppSidebar({ sortModeFor(sectionSortGroupKey(sectionId)), ); } + const defaultGroups = partitionDefaultChannelGroups(unassigned); return { bySection, - unassigned: sortChannelsForSidebar(unassigned, sortModeFor("channels")), + workspace: sortChannelsForSidebar( + defaultGroups.workspace, + sortModeFor("channels"), + ), + projects: sortChannelsForSidebar( + defaultGroups.projects, + sortModeFor("channels"), + ), }; }, [ streamChannels, @@ -433,6 +507,44 @@ export function AppSidebar({ ); }, [streamChannels, starredChannelIds, sortModeFor]); + React.useEffect(() => { + if (selectedView !== "channel" || !selectedChannelId) return; + + if (starredChannelIds?.has(selectedChannelId)) { + setCollapsedGroups((current) => + current.favorites ? { ...current, favorites: false } : current, + ); + return; + } + + const customSectionId = channelAssignments[selectedChannelId]; + if (customSectionId) { + setCollapsedSections((current) => + current[customSectionId] + ? { ...current, [customSectionId]: false } + : current, + ); + return; + } + + const channel = streamChannels.find( + (candidate) => candidate.id === selectedChannelId, + ); + if (!channel) return; + const group: CollapsibleSidebarGroup = isProjectChannel(channel) + ? "projects" + : "workspace"; + setCollapsedGroups((current) => + current[group] ? { ...current, [group]: false } : current, + ); + }, [ + channelAssignments, + selectedChannelId, + selectedView, + starredChannelIds, + streamChannels, + ]); + const handleCreateSectionForChannel = React.useCallback( (channelId: string) => { setCreateSectionState({ open: true, pendingChannelId: channelId }); @@ -608,6 +720,7 @@ export function AppSidebar({ > unreadChannelIds.has(c.id), )} - isCollapsed={collapsedGroups.starred} + isCollapsed={collapsedGroups.favorites} isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} items={starredChannels} @@ -645,9 +758,11 @@ export function AppSidebar({ onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} onSelectChannel={onSelectChannel} - onToggleCollapsed={() => toggleCollapsedGroup("starred")} + onToggleCollapsed={() => + toggleCollapsedGroup("favorites") + } selectedChannelId={selectedChannelId} - title="Starred" + title="Favorites" unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} mutedChannelIds={mutedChannelIds} @@ -732,11 +847,14 @@ export function AppSidebar({ ))} 0} - isCollapsed={collapsedGroups.channels} + dropId="ungrouped-workspace" + hasUnread={sectionBuckets.workspace.some((channel) => + unreadChannelIds.has(channel.id), + )} + isCollapsed={collapsedGroups.workspace} isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} - items={sectionBuckets.unassigned} + items={sectionBuckets.workspace} sortMode={sortModeFor("channels")} onSortModeChange={(mode) => setSortModeFor("channels", mode) @@ -750,9 +868,11 @@ export function AppSidebar({ onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} onSelectChannel={onSelectChannel} - onToggleCollapsed={() => toggleCollapsedGroup("channels")} + onToggleCollapsed={() => + toggleCollapsedGroup("workspace") + } selectedChannelId={selectedChannelId} - title="Channels" + title="Workspace" unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} sections={channelSections} @@ -769,6 +889,58 @@ export function AppSidebar({ onDeleteChannel={requestDeleteChannel} onLeaveChannel={requestLeaveChannel} /> + {sectionBuckets.projects.length > 0 ? ( + + unreadChannelIds.has(channel.id), + )} + isCollapsed={collapsedGroups.projects} + isActiveChannel={selectedView === "channel"} + activeWorkingByChannelId={activeWorkingByChannelId} + items={sectionBuckets.projects} + sortMode={sortModeFor("channels")} + onSortModeChange={(mode) => + setSortModeFor("channels", mode) + } + actionsTestId="section-actions-projects" + listTestId="project-list" + onMarkAllRead={() => { + for (const channel of sectionBuckets.projects) { + onMarkChannelRead( + channel.id, + channel.lastMessageAt, + ); + } + }} + onMarkChannelRead={onMarkChannelRead} + onMarkChannelUnread={onMarkChannelUnread} + onSelectChannel={onSelectChannel} + onToggleCollapsed={() => + toggleCollapsedGroup("projects") + } + selectedChannelId={selectedChannelId} + title="Projects" + unreadChannelCounts={unreadChannelCounts} + unreadChannelIds={unreadChannelIds} + sections={channelSections} + assignments={channelAssignments} + onAssignChannel={assignChannel} + onUnassignChannel={unassignChannel} + onCreateSectionForChannel={ + handleCreateSectionForChannel + } + mutedChannelIds={mutedChannelIds} + onMuteChannel={onMuteChannel} + onUnmuteChannel={onUnmuteChannel} + starredChannelIds={starredChannelIds} + onStarChannel={onStarChannel} + onUnstarChannel={onUnstarChannel} + onDeleteChannel={requestDeleteChannel} + onLeaveChannel={requestLeaveChannel} + /> + ) : null} void; onSelectAgents: () => void; onSelectHome: () => void; onSelectProjects: () => void; @@ -82,6 +84,7 @@ export function AppSidebarPinnedHeader({ export function AppSidebarPrimaryMenu({ homeBadgeCount, + onSelectAlerts, onSelectAgents, onSelectHome, onSelectProjects, @@ -115,6 +118,18 @@ export function AppSidebarPrimaryMenu({ ) : null} + + + + Alerts + + void; + onUnstarChannel?: (channelId: string) => void; +}) { + const action = isStarred ? onUnstarChannel : onStarChannel; + if (!action) return null; + + const label = isStarred + ? `Remove ${channel.name} from favorites` + : `Add ${channel.name} to favorites`; + + return ( + { + event.preventDefault(); + event.stopPropagation(); + action(channel.id); + }} + showOnHover={!isStarred} + title={label} + type="button" + > + + + ); +} + const SORT_OPTIONS: { value: ChannelSortMode; label: string }[] = [ { value: "recent", label: "Recent" }, { value: "alpha", label: "A–Z" }, @@ -290,6 +329,7 @@ export function SectionActionsMenu({ function ChannelSectionHeader({ contentId, + count, isCollapsed, onToggleCollapsed, title, @@ -297,6 +337,7 @@ function ChannelSectionHeader({ actions, }: { contentId: string; + count: number; isCollapsed: boolean; onToggleCollapsed: () => void; title: string; @@ -315,6 +356,9 @@ function ChannelSectionHeader({ type="button" > {title} + + {count} + @@ -496,6 +548,7 @@ export function ChannelGroupSection({ > {sectionContent} + + {sectionContent} + ) : ( sectionContent ); @@ -754,6 +809,14 @@ export function CustomChannelSection({ onSelectChannel={onSelectChannel} /> + diff --git a/desktop/src/features/sidebar/ui/SidebarDnd.tsx b/desktop/src/features/sidebar/ui/SidebarDnd.tsx index 41cc6fe669..bdba151d3b 100644 --- a/desktop/src/features/sidebar/ui/SidebarDnd.tsx +++ b/desktop/src/features/sidebar/ui/SidebarDnd.tsx @@ -83,12 +83,14 @@ export function DroppableSectionBody({ export function DroppableUngroupedBody({ children, className, + dropId = "ungrouped", }: { children: React.ReactNode; className?: string; + dropId?: string; }) { const { setNodeRef, isOver } = useDroppable({ - id: "ungrouped", + id: dropId, data: { type: "ungrouped" } satisfies DndUngroupedData, }); diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 3e39c60a6d..bb62db7cc8 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -40,7 +40,7 @@ import { const SECTION_LABEL_BUTTON_CLASS = "group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground"; const SECTION_LABEL_CHEVRON_CLASS = - "relative size-2.5 shrink-0 text-current opacity-0 transition-[color,opacity] group-hover/sidebar-section:opacity-100 group-hover/section-label:opacity-100 group-focus-within/sidebar-section:opacity-100 group-focus-visible/section-label:opacity-100 group-data-[section-actions-open=true]/sidebar-section:opacity-100"; + "relative size-2.5 shrink-0 text-current opacity-60 transition-[color,opacity] group-hover/sidebar-section:opacity-100 group-hover/section-label:opacity-100 group-focus-within/sidebar-section:opacity-100 group-focus-visible/section-label:opacity-100 group-data-[section-actions-open=true]/sidebar-section:opacity-100"; const SECTION_LABEL_CHEVRON_ICON_CLASS = "absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2"; const SIDEBAR_ROW_ACTION_VISIBILITY_CLASS = @@ -412,6 +412,9 @@ export function SidebarSection({ type="button" > {title} + + {items.length} +
); })}
@@ -122,10 +155,13 @@ export function WorkspaceInbox({
-

You’re all caught up

+

+ {isAlerts ? "No alerts yet" : "Your inbox is clear"} +

- New mentions, replies, action items, and personal-agent updates - will appear here. + {isAlerts + ? "Mentions and replies will appear here without getting lost in channel traffic." + : "Requests appear only when they explicitly require your approval."}

diff --git a/web/src/features/workspace/ui/WorkspacePage.tsx b/web/src/features/workspace/ui/WorkspacePage.tsx index 29d82740f2..920d35b32a 100644 --- a/web/src/features/workspace/ui/WorkspacePage.tsx +++ b/web/src/features/workspace/ui/WorkspacePage.tsx @@ -1,4 +1,4 @@ -import { ChevronLeft, Hash, Lock, Menu, Users, X } from "lucide-react"; +import { ChevronLeft, Hash, Lock, Menu, Star, Users, X } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import * as React from "react"; @@ -9,6 +9,7 @@ import { IdentityGate } from "./IdentityGate"; import { EmptyMembership } from "./EmptyMembership"; import { WorkspaceSidebar } from "./WorkspaceSidebar"; import { WorkspaceInbox } from "./WorkspaceInbox"; +import { WorkspaceAgents } from "./WorkspaceAgents"; import { WorkspaceGuide } from "./WorkspaceGuide"; import { WorkspaceSettings } from "./WorkspaceSettings"; import { WorkspaceComposer } from "./WorkspaceComposer"; @@ -36,6 +37,33 @@ import { import { useWorkspaceReactions } from "@/features/workspace/useWorkspaceReactions"; import { useWorkspaceReadState } from "@/features/workspace/workspace-read-state"; +type WorkspaceView = "agents" | "alerts" | "channel" | "inbox"; + +const STARRED_CHANNELS_STORAGE_PREFIX = "buzz.web.starred-channels.v1"; + +function starredChannelsStorageKey(pubkey: string) { + return `${STARRED_CHANNELS_STORAGE_PREFIX}:${pubkey.toLowerCase()}`; +} + +function readStarredChannels(pubkey: string) { + if (!pubkey) return new Set(); + try { + const value = JSON.parse( + localStorage.getItem(starredChannelsStorageKey(pubkey)) ?? "[]", + ); + return new Set( + Array.isArray(value) + ? value.filter( + (channelId): channelId is string => + typeof channelId === "string" && channelId.length > 0, + ) + : [], + ); + } catch { + return new Set(); + } +} + export function WorkspacePage() { const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -50,8 +78,10 @@ export function WorkspacePage() { const [activeChannelId, setActiveChannelId] = React.useState( () => localStorage.getItem("buzz.web.active-channel"), ); - const [workspaceView, setWorkspaceView] = React.useState<"channel" | "inbox">( - "channel", + const [workspaceView, setWorkspaceView] = + React.useState("channel"); + const [starredChannelIds, setStarredChannelIds] = React.useState>( + () => new Set(), ); const [sidebarOpen, setSidebarOpen] = React.useState(false); const [threadRootId, setThreadRootId] = React.useState(null); @@ -80,6 +110,9 @@ export function WorkspacePage() { }); const channels = channelsQuery.data ?? []; + React.useEffect(() => { + setStarredChannelIds(readStarredChannels(identity?.pubkey ?? "")); + }, [identity?.pubkey]); React.useEffect(() => { if ( channels.length && @@ -113,6 +146,9 @@ export function WorkspacePage() { [messagesQuery.data], ); const { + alertItems, + dismissAllInboxItems, + dismissInboxItem, inboxItems, markAllRead, markInboxItemRead, @@ -124,6 +160,24 @@ export function WorkspacePage() { currentMessages: materialized, identityPubkey: identity?.pubkey, }); + const alertsUnreadCount = alertItems.filter((item) => !item.isRead).length; + const inboxUnreadCount = inboxItems.filter((item) => !item.isRead).length; + const toggleStarredChannel = React.useCallback( + (channelId: string) => { + if (!identity) return; + setStarredChannelIds((current) => { + const next = new Set(current); + if (next.has(channelId)) next.delete(channelId); + else next.add(channelId); + localStorage.setItem( + starredChannelsStorageKey(identity.pubkey), + JSON.stringify([...next]), + ); + return next; + }); + }, + [identity], + ); const { reactionActorPubkeys, reactions, toggleReaction } = useWorkspaceReactions(materialized, identity?.pubkey ?? ""); const profilePubkeys = React.useMemo( @@ -283,8 +337,8 @@ export function WorkspacePage() { } const profileFor = (pubkey: string): WorkspaceProfile => - profilesQuery.data?.get(pubkey) ?? - agentsQuery.data?.find((profile) => profile.pubkey === pubkey) ?? { + agentsQuery.data?.find((profile) => profile.pubkey === pubkey) ?? + profilesQuery.data?.get(pubkey) ?? { pubkey, name: pubkey === identity.pubkey @@ -345,9 +399,11 @@ export function WorkspacePage() { agents={agentsQuery.data ?? []} channels={channels} identity={identity} - inboxOpen={workspaceView === "inbox"} - inboxUnreadCount={inboxItems.length} + alertsUnreadCount={alertsUnreadCount} + inboxUnreadCount={inboxUnreadCount} + selectedView={workspaceView} profile={currentProfile} + starredChannelIds={starredChannelIds} unreadChannelIds={unreadChannelIds} open={sidebarOpen} onClose={() => setSidebarOpen(false)} @@ -356,6 +412,9 @@ export function WorkspacePage() { onOpenSettings={() => setSettingsOpen(true)} onOpenGuide={() => setGuideOpen(true)} onOpenInbox={() => setWorkspaceView("inbox")} + onOpenAlerts={() => setWorkspaceView("alerts")} + onOpenAgents={() => setWorkspaceView("agents")} + onToggleStar={toggleStarredChannel} onSelectChannel={(channelId) => { setActiveChannelId(channelId); setWorkspaceView("channel"); @@ -369,7 +428,29 @@ export function WorkspacePage() { { + if (!item.channelId) { + markInboxItemRead(item); + return; + } + markInboxItemRead(item); + setActiveChannelId(item.channelId); + setWorkspaceView("channel"); + setThreadRootId(null); + setExpandedThreadIds(new Set()); + }} + profileFor={profileFor} + /> + ) : workspaceView === "alerts" ? ( + { if (!item.channelId) { @@ -384,6 +465,12 @@ export function WorkspacePage() { }} profileFor={profileFor} /> + ) : workspaceView === "agents" ? ( + addAgentMutation.mutate(agent)} + /> ) : ( <>
) : null} + {activeChannel.type !== "dm" ? ( + + ) : null}
diff --git a/web/src/features/workspace/ui/WorkspaceSidebar.tsx b/web/src/features/workspace/ui/WorkspaceSidebar.tsx index 53a77244aa..990f688da2 100644 --- a/web/src/features/workspace/ui/WorkspaceSidebar.tsx +++ b/web/src/features/workspace/ui/WorkspaceSidebar.tsx @@ -1,4 +1,5 @@ import { + Bell, Bot, BookOpen, Check, @@ -10,6 +11,7 @@ import { Plus, Settings, Sparkles, + Star, X, } from "lucide-react"; import { useEffect, useState } from "react"; @@ -43,7 +45,7 @@ const PROJECT_CHANNEL_NAMES = new Set([ const SECTION_STORAGE_PREFIX = "buzz-web:channel-sections:v1"; -type ChannelSectionId = "workspace" | "projects"; +type ChannelSectionId = "favorites" | "workspace" | "projects"; type AgentSectionId = "hostedAgents" | "privateAgents" | "sharedAgents"; type CollapsibleSectionId = ChannelSectionId | AgentSectionId; type CollapsedSections = Record; @@ -54,6 +56,7 @@ function sectionStorageKey(pubkey: string): string { function readCollapsedSections(pubkey: string): CollapsedSections { const fallback = { + favorites: false, workspace: false, projects: false, hostedAgents: false, @@ -65,6 +68,7 @@ function readCollapsedSections(pubkey: string): CollapsedSections { if (!stored) return fallback; const parsed = JSON.parse(stored) as Partial; return { + favorites: parsed.favorites === true, workspace: parsed.workspace === true, projects: parsed.projects === true, hostedAgents: parsed.hostedAgents === true, @@ -185,7 +189,8 @@ export function WorkspaceSidebar({ identity, profile, inboxUnreadCount, - inboxOpen, + alertsUnreadCount, + selectedView, unreadChannelIds, channels, agents, @@ -197,12 +202,17 @@ export function WorkspaceSidebar({ onOpenSettings, onOpenGuide, onOpenInbox, + onOpenAlerts, + onOpenAgents, onAddAgent, + starredChannelIds, + onToggleStar, }: { identity: BrowserIdentity; profile: WorkspaceProfile; inboxUnreadCount: number; - inboxOpen: boolean; + alertsUnreadCount: number; + selectedView: "agents" | "alerts" | "channel" | "inbox"; unreadChannelIds: ReadonlySet; channels: WorkspaceChannel[]; agents: WorkspaceProfile[]; @@ -214,14 +224,25 @@ export function WorkspaceSidebar({ onOpenSettings: () => void; onOpenGuide: () => void; onOpenInbox: () => void; + onOpenAlerts: () => void; + onOpenAgents: () => void; onAddAgent: (agent: WorkspaceProfile) => void; + starredChannelIds: ReadonlySet; + onToggleStar: (channelId: string) => void; }) { const streams = channels.filter((channel) => channel.type !== "dm"); const directMessages = channels.filter((channel) => channel.type === "dm"); + const favoriteChannels = streams.filter((channel) => + starredChannelIds.has(channel.id), + ); const workspaceChannels = streams.filter( - (channel) => !isProjectChannel(channel), + (channel) => + !starredChannelIds.has(channel.id) && !isProjectChannel(channel), + ); + const projectChannels = streams.filter( + (channel) => + !starredChannelIds.has(channel.id) && isProjectChannel(channel), ); - const projectChannels = streams.filter(isProjectChannel); const privateAgents = agents.filter( (agent) => agent.accessTier === "personal" || agent.accessTier === "admin", ); @@ -311,33 +332,79 @@ export function WorkspaceSidebar({ className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-4" data-testid="workspace-sidebar-scroll" > - +
+ + + +

@@ -353,6 +420,20 @@ export function WorkspaceSidebar({

+ {favoriteChannels.length ? ( + toggleSection("favorites")} + onToggleStar={onToggleStar} + starredChannelIds={starredChannelIds} + unreadChannelIds={unreadChannelIds} + /> + ) : null} toggleSection("workspace")} + onToggleStar={onToggleStar} + starredChannelIds={starredChannelIds} unreadChannelIds={unreadChannelIds} /> {projectChannels.length ? ( @@ -372,6 +455,8 @@ export function WorkspaceSidebar({ label="Projects" onSelectChannel={selectChannel} onToggle={() => toggleSection("projects")} + onToggleStar={onToggleStar} + starredChannelIds={starredChannelIds} unreadChannelIds={unreadChannelIds} /> ) : null} @@ -390,6 +475,8 @@ export function WorkspaceSidebar({ channel={channel} key={channel.id} unread={unreadChannelIds.has(channel.id)} + starred={starredChannelIds.has(channel.id)} + onToggleStar={() => onToggleStar(channel.id)} onSelect={() => { onSelectChannel(channel.id); onClose(); @@ -603,6 +690,8 @@ function ChannelSection({ onToggle, onSelectChannel, unreadChannelIds, + starredChannelIds, + onToggleStar, }: { id: ChannelSectionId; label: string; @@ -612,6 +701,8 @@ function ChannelSection({ onToggle: () => void; onSelectChannel: (channelId: string) => void; unreadChannelIds: ReadonlySet; + starredChannelIds: ReadonlySet; + onToggleStar: (channelId: string) => void; }) { const contentId = `channel-section-${id}`; return ( @@ -640,6 +731,8 @@ function ChannelSection({ channel={channel} key={channel.id} unread={unreadChannelIds.has(channel.id)} + starred={starredChannelIds.has(channel.id)} + onToggleStar={() => onToggleStar(channel.id)} onSelect={() => onSelectChannel(channel.id)} /> ))} @@ -654,38 +747,61 @@ function ChannelButton({ active, onSelect, unread = false, + starred = false, + onToggleStar, }: { channel: WorkspaceChannel; active: boolean; onSelect: () => void; unread?: boolean; + starred?: boolean; + onToggleStar: () => void; }) { return ( - + {channel.type !== "dm" ? ( + ) : null} - +
); } diff --git a/web/src/features/workspace/workspace-api.ts b/web/src/features/workspace/workspace-api.ts index a3e9f9a251..7db0e08039 100644 --- a/web/src/features/workspace/workspace-api.ts +++ b/web/src/features/workspace/workspace-api.ts @@ -13,6 +13,7 @@ export const KIND_DELETION = 5; export const KIND_REACTION = 7; export const KIND_STREAM_MESSAGE = 9; export const KIND_AGENT_PROFILE = 10100; +export const KIND_COMMUNITY_MEMBERS = 13534; export const KIND_ARCHIVED_IDENTITIES = 13535; export const KIND_READ_STATE = 30078; export const KIND_CHANNEL_METADATA = 39000; @@ -21,8 +22,11 @@ export const KIND_STREAM_MESSAGE_V2 = 40002; export const KIND_STREAM_MESSAGE_EDIT = 40003; export const KIND_SYSTEM_MESSAGE = 40099; export const KIND_MANAGED_AGENT = 30177; +export const KIND_HOSTED_AGENT_CONFIG = 30179; export const KIND_NIP29_DELETE = 9005; +const HOSTED_AGENT_CONFIG_SCHEMA = "buzz.hosted-agent-config.v1"; + const MESSAGE_KINDS = [ KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, @@ -61,6 +65,7 @@ export type WorkspaceProfile = { audience?: "community" | "owner"; ownerPubkey?: string; accessTier?: "shared" | "personal" | "admin"; + model?: string; }; export type WorkspaceMessage = NostrEvent & { @@ -110,6 +115,110 @@ function dedupeReplaceable(events: NostrEvent[]): NostrEvent[] { return [...latest.values()]; } +function parsedContent(event: NostrEvent): Record { + try { + return JSON.parse(event.content) as Record; + } catch { + return {}; + } +} + +function isHostedAgentConfigEvent(event: NostrEvent): boolean { + if (event.kind === KIND_HOSTED_AGENT_CONFIG) return true; + if (event.kind !== KIND_MANAGED_AGENT) return false; + const content = parsedContent(event); + return ( + content.schema === HOSTED_AGENT_CONFIG_SCHEMA && + (firstTag(event, "d") ?? "").startsWith("hosted-agent:") + ); +} + +function hostedAgentConfigTarget(event: NostrEvent): string | null { + if (!isHostedAgentConfigEvent(event)) return null; + const content = parsedContent(event); + const declared = content.agent_pubkey; + if (typeof declared === "string" && declared.trim()) { + return declared.trim().toLowerCase(); + } + const dTag = firstTag(event, "d") ?? ""; + const target = dTag.startsWith("hosted-agent:") + ? dTag.slice("hosted-agent:".length) + : dTag; + return target.trim().toLowerCase() || null; +} + +function communityAdminPubkeys(events: NostrEvent[]): Set { + const latest = [...events].sort( + (left, right) => + right.created_at - left.created_at || right.id.localeCompare(left.id), + )[0]; + if (!latest) return new Set(); + return new Set( + latest.tags + .filter( + (tag) => + tag[0] === "member" && + (tag[2] === "owner" || tag[2] === "admin") && + typeof tag[1] === "string", + ) + .map((tag) => tag[1].toLowerCase()), + ); +} + +function applyHostedAgentConfigs( + profiles: Map, + configEvents: NostrEvent[], + adminPubkeys: ReadonlySet, +) { + const latestByTarget = new Map(); + for (const event of configEvents) { + const target = hostedAgentConfigTarget(event); + const profile = target ? profiles.get(target) : undefined; + if (!target || !profile) continue; + const author = event.pubkey.toLowerCase(); + if ( + !adminPubkeys.has(author) && + profile.ownerPubkey?.toLowerCase() !== author + ) { + continue; + } + const current = latestByTarget.get(target); + if ( + !current || + event.created_at > current.created_at || + (event.created_at === current.created_at && event.id > current.id) + ) { + latestByTarget.set(target, event); + } + } + + for (const [target, event] of latestByTarget) { + const profile = profiles.get(target); + if (!profile) continue; + const content = parsedContent(event); + const configuredName = + typeof content.name === "string" ? content.name.trim() : ""; + const avatarUrl = content.avatar_url; + const model = content.model; + profiles.set(target, { + ...profile, + name: configuredName || profile.name, + picture: + typeof avatarUrl === "string" + ? avatarUrl.trim() || undefined + : avatarUrl === null + ? undefined + : profile.picture, + model: + typeof model === "string" + ? model.trim() || undefined + : model === null + ? undefined + : profile.model, + }); + } +} + function parseThread(event: NostrEvent): { rootEventId: string | null; parentEventId: string | null; @@ -213,7 +322,7 @@ export async function listProfiles( authors: unique, limit: Math.min(500, unique.length * 3), }), - ); + ).filter((event) => !isHostedAgentConfigEvent(event)); const profiles = new Map(); for (const event of events) { let content: Record = {}; @@ -255,14 +364,25 @@ export async function listProfiles( export async function listAgents( viewerPubkey: string, ): Promise { - const [agentEvents, archivedPubkeys] = await Promise.all([ - queryEvents(relayWsUrl(), { - kinds: [KIND_AGENT_PROFILE, KIND_MANAGED_AGENT], - limit: 200, - }), - listArchivedIdentities(), - ]); - const events = dedupeReplaceable(agentEvents); + const [agentEvents, configEvents, membershipEvents, archivedPubkeys] = + await Promise.all([ + queryEvents(relayWsUrl(), { + kinds: [KIND_AGENT_PROFILE, KIND_MANAGED_AGENT], + limit: 200, + }), + queryEvents(relayWsUrl(), { + kinds: [KIND_HOSTED_AGENT_CONFIG, KIND_MANAGED_AGENT], + limit: 500, + }), + queryEvents(relayWsUrl(), { + kinds: [KIND_COMMUNITY_MEMBERS], + limit: 50, + }), + listArchivedIdentities(), + ]); + const events = dedupeReplaceable(agentEvents).filter( + (event) => !isHostedAgentConfigEvent(event), + ); const agentPubkeys = events.map( (event) => (event.kind === KIND_MANAGED_AGENT && firstTag(event, "d")) || @@ -308,8 +428,14 @@ export async function listAgents( content.access_tier === "personal" || content.access_tier === "admin" ? content.access_tier : "shared", + model: typeof content.model === "string" ? content.model : undefined, }); } + applyHostedAgentConfigs( + profiles, + configEvents.filter(isHostedAgentConfigEvent), + communityAdminPubkeys(membershipEvents), + ); return [...profiles.values()] .filter( (profile) => diff --git a/web/src/features/workspace/workspace-inbox-policy.d.mts b/web/src/features/workspace/workspace-inbox-policy.d.mts new file mode 100644 index 0000000000..d822bc897b --- /dev/null +++ b/web/src/features/workspace/workspace-inbox-policy.d.mts @@ -0,0 +1,9 @@ +export const APPROVAL_REQUEST_KIND: 46010; +export const INBOX_DISMISS_CONTEXT_PREFIX: "inbox-dismiss:"; + +export function isTargetedApprovalRequest( + event: { kind: number; tags: string[][] }, + pubkey: string, +): boolean; + +export function inboxDismissContextId(eventId: string): string; diff --git a/web/src/features/workspace/workspace-inbox-policy.mjs b/web/src/features/workspace/workspace-inbox-policy.mjs new file mode 100644 index 0000000000..6a69905437 --- /dev/null +++ b/web/src/features/workspace/workspace-inbox-policy.mjs @@ -0,0 +1,16 @@ +export const APPROVAL_REQUEST_KIND = 46010; +export const INBOX_DISMISS_CONTEXT_PREFIX = "inbox-dismiss:"; + +export function isTargetedApprovalRequest(event, pubkey) { + const normalizedPubkey = pubkey.toLowerCase(); + return ( + event.kind === APPROVAL_REQUEST_KIND && + event.tags.some( + (tag) => tag[0] === "p" && tag[1]?.toLowerCase() === normalizedPubkey, + ) + ); +} + +export function inboxDismissContextId(eventId) { + return `${INBOX_DISMISS_CONTEXT_PREFIX}${eventId}`; +} diff --git a/web/src/features/workspace/workspace-read-state.ts b/web/src/features/workspace/workspace-read-state.ts index 99684e9084..d01d6d04f1 100644 --- a/web/src/features/workspace/workspace-read-state.ts +++ b/web/src/features/workspace/workspace-read-state.ts @@ -15,6 +15,11 @@ import { type WorkspaceChannel, type WorkspaceMessage, } from "./workspace-api"; +import { + APPROVAL_REQUEST_KIND, + inboxDismissContextId, + isTargetedApprovalRequest, +} from "./workspace-inbox-policy.mjs"; const READ_STATE_D_TAG_PREFIX = "read-state:"; const READ_STATE_HORIZON_SECONDS = 7 * 24 * 60 * 60; @@ -27,13 +32,12 @@ const CONVERSATIONAL_KINDS = [9, 40002] as const; // their dot semantics, while these sources feed the Inbox only when addressed // to the current identity. const MENTION_KINDS = [9, 40002, 1, 45001, 45003] as const; -const NEEDS_ACTION_KINDS = [46010, 46011, 46012] as const; const AGENT_ACTIVITY_KINDS = [ 43001, 43002, 43003, 43004, 43005, 43006, ] as const; const INBOX_SOURCE_KINDS = [ ...MENTION_KINDS, - ...NEEDS_ACTION_KINDS, + APPROVAL_REQUEST_KIND, ...AGENT_ACTIVITY_KINDS, ] as const; @@ -55,8 +59,10 @@ export type WorkspaceInboxItem = { channelId: string | null; content: string; contextId: string; + dismissContextId: string; createdAt: number; id: string; + isRead: boolean; pubkey: string; }; @@ -248,11 +254,7 @@ function categoryForMessage(args: { channel?: WorkspaceChannel; }): WorkspaceInboxCategory | null { const { channel, event, participatedThreadRootIds, pubkey } = args; - if ( - NEEDS_ACTION_KINDS.includes( - event.kind as (typeof NEEDS_ACTION_KINDS)[number], - ) - ) { + if (isTargetedApprovalRequest(event, pubkey)) { return "needs_action"; } if ( @@ -275,16 +277,6 @@ function categoryForMessage(args: { ) { return "mention"; } - if ( - event.tags.some( - (tag) => - (tag[0] === "status" && - /^(needs[_-]action|required)$/i.test(tag[1] ?? "")) || - (tag[0] === "action" && /^(required|approval)$/i.test(tag[1] ?? "")), - ) - ) { - return "needs_action"; - } if (event.rootEventId && participatedThreadRootIds.has(event.rootEventId)) { return "reply"; } @@ -346,16 +338,14 @@ export function deriveWorkspaceUnread(args: { const unreadChannelIds = new Set(); const unreadChannelCounts = new Map(); const inboxItems: WorkspaceInboxItem[] = []; + const alertItems: WorkspaceInboxItem[] = []; for (const [channelId, events] of eventsByChannel) { let unreadCount = 0; for (const event of events) { - if ( - !isExternalMessage(event, pubkey) || - event.created_at <= readMarkerForEvent(markers, channelId, event) - ) { - continue; - } - if (isConversationalMessage(event)) unreadCount += 1; + if (!isExternalMessage(event, pubkey)) continue; + const isRead = + event.created_at <= readMarkerForEvent(markers, channelId, event); + if (!isRead && isConversationalMessage(event)) unreadCount += 1; const category = categoryForMessage({ event, participatedThreadRootIds, @@ -363,15 +353,27 @@ export function deriveWorkspaceUnread(args: { channel: channelsById.get(channelId), }); if (category) { - inboxItems.push({ + const dismissContextId = inboxDismissContextId(event.id); + const item = { category, channelId: channelsById.has(channelId) ? channelId : null, content: event.content, contextId: channelId, + dismissContextId, createdAt: event.created_at, id: event.id, + isRead, pubkey: event.pubkey, - }); + } satisfies WorkspaceInboxItem; + if (category === "mention" || category === "reply") { + alertItems.push(item); + } else if ( + category === "needs_action" && + isTargetedApprovalRequest(event, pubkey) && + (markers.get(dismissContextId) ?? 0) < event.created_at + ) { + inboxItems.push(item); + } } } if (unreadCount > 0) { @@ -380,6 +382,10 @@ export function deriveWorkspaceUnread(args: { } } return { + alertItems: alertItems.sort( + (left, right) => + right.createdAt - left.createdAt || right.id.localeCompare(left.id), + ), inboxItems: inboxItems.sort( (left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id), @@ -650,7 +656,43 @@ export function useWorkspaceReadState({ [persistAndPublish], ); + const dismissInboxItem = React.useCallback( + (item: WorkspaceInboxItem) => { + setMarkers((current) => { + if ((current.get(item.dismissContextId) ?? 0) >= item.createdAt) { + return current; + } + const next = new Map(current).set( + item.dismissContextId, + item.createdAt, + ); + persistAndPublish(next); + return next; + }); + }, + [persistAndPublish], + ); + + const dismissAllInboxItems = React.useCallback(() => { + setMarkers((current) => { + const next = new Map(current); + let changed = false; + for (const item of unread.inboxItems) { + if ((next.get(item.dismissContextId) ?? 0) < item.createdAt) { + next.set(item.dismissContextId, item.createdAt); + changed = true; + } + } + if (!changed) return current; + persistAndPublish(next); + return next; + }); + }, [persistAndPublish, unread.inboxItems]); + return { + alertItems: unread.alertItems, + dismissAllInboxItems, + dismissInboxItem, inboxItems: unread.inboxItems, markAllRead, markInboxItemRead, diff --git a/web/src/shared/lib/nostr-client.ts b/web/src/shared/lib/nostr-client.ts index 7273ba57d2..ae5fbf3e3d 100644 --- a/web/src/shared/lib/nostr-client.ts +++ b/web/src/shared/lib/nostr-client.ts @@ -275,83 +275,129 @@ export function subscribeEvents( onEvent: (event: NostrEvent) => void, onStatus?: (status: "connecting" | "live" | "closed", error?: Error) => void, ): () => void { - const ws = new WebSocket(wsUrl); - const subId = `live-${crypto.randomUUID()}`; let stopped = false; - let authEventId: string | null = null; - let reqSent = false; + let reconnectAttempt = 0; + let reconnectTimer: number | null = null; + let ws: WebSocket | null = null; + let activeSubId: string | null = null; const close = () => { if (stopped) return; stopped = true; - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(["CLOSE", subId])); + if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); + if (ws?.readyState === WebSocket.OPEN && activeSubId) { + ws.send(JSON.stringify(["CLOSE", activeSubId])); } - ws.close(); + ws?.close(); }; - const sendReq = () => { - if (stopped || reqSent || ws.readyState !== WebSocket.OPEN) return; - reqSent = true; - ws.send(JSON.stringify(["REQ", subId, filter])); + const scheduleReconnect = (error?: Error) => { + if (stopped || reconnectTimer !== null) return; + onStatus?.("closed", error); + const delay = Math.min(1_000 * 2 ** reconnectAttempt, 15_000); + reconnectAttempt += 1; + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + connect(); + }, delay); }; - onStatus?.("connecting"); - ws.addEventListener("message", async (message) => { - const data = parseEnvelope(message.data); - if (!data || stopped) return; - if (data[0] === "AUTH" && typeof data[1] === "string") { - try { - const auth = await signNostrEvent(makeAuthEvent(wsUrl, data[1]), { - requireNip07: true, - }); - authEventId = auth.id; - ws.send(JSON.stringify(["AUTH", auth])); - } catch (error) { - onStatus?.( - "closed", - error instanceof Error ? error : new Error("Authentication failed."), - ); - close(); + const connect = () => { + if (stopped) return; + const socket = new WebSocket(wsUrl); + const subId = `live-${crypto.randomUUID()}`; + let authEventId: string | null = null; + let reqSent = false; + let terminalClose = false; + ws = socket; + activeSubId = subId; + onStatus?.("connecting"); + + const sendReq = () => { + if ( + stopped || + reqSent || + socket.readyState !== WebSocket.OPEN || + ws !== socket + ) { + return; } - return; - } - if (data[0] === "OK" && data[1] === authEventId) { - if (data[2] === true) sendReq(); - else { + reqSent = true; + socket.send(JSON.stringify(["REQ", subId, filter])); + }; + + socket.addEventListener("message", async (message) => { + const data = parseEnvelope(message.data); + if (!data || stopped || ws !== socket) return; + if (data[0] === "AUTH" && typeof data[1] === "string") { + try { + const auth = await signNostrEvent(makeAuthEvent(wsUrl, data[1]), { + requireNip07: true, + }); + authEventId = auth.id; + socket.send(JSON.stringify(["AUTH", auth])); + } catch (error) { + terminalClose = true; + onStatus?.( + "closed", + error instanceof Error + ? error + : new Error("Authentication failed."), + ); + socket.close(); + } + return; + } + if (data[0] === "OK" && data[1] === authEventId) { + if (data[2] === true) sendReq(); + else { + terminalClose = true; + onStatus?.( + "closed", + new Error( + typeof data[3] === "string" + ? data[3] + : "Relay authentication failed.", + ), + ); + socket.close(); + } + return; + } + if (data[0] === "EVENT" && data[1] === subId && data[2]) { + onEvent(data[2] as NostrEvent); + } else if (data[0] === "EOSE" && data[1] === subId) { + reconnectAttempt = 0; + onStatus?.("live"); + } else if (data[0] === "CLOSED" && data[1] === subId) { + terminalClose = true; onStatus?.( "closed", new Error( - typeof data[3] === "string" - ? data[3] - : "Relay authentication failed.", + typeof data[2] === "string" + ? data[2] + : "The relay closed the subscription.", ), ); - close(); + socket.close(); } - return; - } - if (data[0] === "EVENT" && data[1] === subId && data[2]) { - onEvent(data[2] as NostrEvent); - } else if (data[0] === "EOSE" && data[1] === subId) { - onStatus?.("live"); - } else if (data[0] === "CLOSED" && data[1] === subId) { - onStatus?.( - "closed", - new Error( - typeof data[2] === "string" - ? data[2] - : "The relay closed the subscription.", - ), - ); - close(); - } - }); - ws.addEventListener("error", () => { - onStatus?.("closed", new Error("The realtime connection failed.")); - }); - ws.addEventListener("close", () => { - if (!stopped) onStatus?.("closed"); - }); + }); + socket.addEventListener("error", () => { + if (!stopped && ws === socket) socket.close(); + }); + socket.addEventListener("close", () => { + if (ws === socket) { + ws = null; + activeSubId = null; + } + if (!stopped && !terminalClose) { + scheduleReconnect( + new Error("The realtime connection was interrupted."), + ); + } + }); + }; + + connect(); return close; } diff --git a/web/tests/e2e/helpers/workspaceRelayMock.ts b/web/tests/e2e/helpers/workspaceRelayMock.ts index 74034a8775..d7c27b91fc 100644 --- a/web/tests/e2e/helpers/workspaceRelayMock.ts +++ b/web/tests/e2e/helpers/workspaceRelayMock.ts @@ -3,10 +3,18 @@ import type { Page } from "@playwright/test"; export async function installWorkspaceRelayMock( page: Page, viewerPubkey: string, - options: { generalMemberPubkeys?: string[] } = {}, + options: { + generalMemberPubkeys?: string[]; + hostedAgentConfig?: { + agentPubkey: string; + name: string; + avatarUrl: string; + model: string; + }; + } = {}, ) { await page.addInitScript( - ({ pubkey, generalMemberPubkeys }) => { + ({ pubkey, generalMemberPubkeys, hostedAgentConfig }) => { const event = ( kind: number, eventPubkey: string, @@ -36,6 +44,23 @@ export async function installWorkspaceRelayMock( (index + 10).toString(16), ); }); + const hostedConfigEvents = hostedAgentConfig + ? [ + event( + 30179, + pubkey, + [["d", hostedAgentConfig.agentPubkey]], + JSON.stringify({ + schema: "buzz.hosted-agent-config.v1", + agent_pubkey: hostedAgentConfig.agentPubkey, + name: hostedAgentConfig.name, + avatar_url: hostedAgentConfig.avatarUrl, + model: hostedAgentConfig.model, + }), + "88", + ), + ] + : []; const sockets = new Set(); const publishedEvents: ReturnType[] = []; let reactionQueryCount = 0; @@ -180,8 +205,20 @@ export async function installWorkspaceRelayMock( "4", ), ]; + } else if (kinds.includes(30179)) { + events = hostedConfigEvents; } else if (kinds.includes(10100) || kinds.includes(30177)) { events = agentEvents; + } else if (kinds.includes(13534)) { + events = [ + event( + 13534, + "f".repeat(64), + [["member", pubkey, "owner"]], + "", + "89", + ), + ]; } else if ( kinds.length === 1 && kinds.includes(0) && @@ -342,6 +379,7 @@ export async function installWorkspaceRelayMock( { pubkey: viewerPubkey, generalMemberPubkeys: options.generalMemberPubkeys ?? [], + hostedAgentConfig: options.hostedAgentConfig ?? null, }, ); } diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts index 855fe01c3e..6932e325c0 100644 --- a/web/tests/e2e/smoke.spec.ts +++ b/web/tests/e2e/smoke.spec.ts @@ -64,7 +64,7 @@ test("web workspace preserves profiles and applies live-event parity rules", asy "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==", }); - const randomChannel = page.getByRole("button", { name: "random" }); + const randomChannel = page.locator('button[aria-label^="random"]'); await expect(randomChannel).toBeVisible(); for (const kind of [40003, 40099, 5, 9005]) { await page.evaluate( @@ -123,8 +123,16 @@ test("web workspace preserves profiles and applies live-event parity rules", asy "random, unread messages", ); + await page.getByRole("button", { name: "Add random to favorites" }).click(); + await expect(page.getByText("Favorites", { exact: true })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Remove random from favorites" }), + ).toBeVisible(); + const inboxButton = page.getByTestId("workspace-inbox-button"); + const alertsButton = page.getByTestId("workspace-alerts-button"); await expect(inboxButton).toHaveAttribute("aria-label", "Inbox"); + await expect(alertsButton).toHaveAttribute("aria-label", "Alerts"); await page.evaluate((pubkey) => { const helpers = window as typeof window & { __BUZZ_WEB_E2E_EMIT__: (event: unknown) => void; @@ -149,12 +157,12 @@ test("web workspace preserves profiles and applies live-event parity rules", asy ), ); }, viewerPubkey); - await expect(inboxButton).toHaveAttribute( + await expect(alertsButton).toHaveAttribute( "aria-label", - "Inbox, 1 unread notifications", + "Alerts, 1 unread notifications", ); - await inboxButton.click(); - await expect(page.getByTestId("workspace-inbox")).toBeVisible(); + await alertsButton.click(); + await expect(page.getByTestId("workspace-alerts")).toBeVisible(); await expect(page.getByText("Please review this")).toBeVisible(); await page.evaluate((pubkey) => { @@ -180,14 +188,20 @@ test("web workspace preserves profiles and applies live-event parity rules", asy }, viewerPubkey); await expect(inboxButton).toHaveAttribute( "aria-label", - "Inbox, 2 unread notifications", + "Inbox, 1 unread notifications", ); + await inboxButton.click(); await page.getByRole("button", { name: /Approval required/ }).click(); await expect(page.getByTestId("workspace-inbox")).toBeVisible(); - await expect(inboxButton).toHaveAttribute( - "aria-label", - "Inbox, 1 unread notifications", - ); + await expect(inboxButton).toHaveAttribute("aria-label", "Inbox"); + + await page.getByTestId("workspace-agents-button").click(); + await expect(page.getByTestId("workspace-agents")).toBeVisible(); + await expect( + page.getByTestId("workspace-agents").getByText("Workspace Agent 1", { + exact: true, + }), + ).toBeVisible(); await expect .poll(() => diff --git a/web/tests/e2e/workspace-identity-and-agents.spec.ts b/web/tests/e2e/workspace-identity-and-agents.spec.ts index 9600526e1f..ab2808aaad 100644 --- a/web/tests/e2e/workspace-identity-and-agents.spec.ts +++ b/web/tests/e2e/workspace-identity-and-agents.spec.ts @@ -190,6 +190,71 @@ test("mentioning an eligible hosted agent adds it before the message", async ({ ).toBeVisible(); }); +test("admin-edited hosted agent identity is shared across the web roster and mentions", async ({ + page, +}) => { + const secretKey = generateSecretKey(); + const viewerPubkey = getPublicKey(secretKey); + const agentPubkey = "7".padStart(64, "0"); + const avatarUrl = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="; + await installWorkspaceRelayMock(page, viewerPubkey, { + hostedAgentConfig: { + agentPubkey, + name: "Sylar", + avatarUrl, + model: "gpt-5.6-terra", + }, + }); + await page.goto("/"); + await page.getByLabel("Display name").fill("Vikram"); + await page.getByLabel("Recovery key").fill(nsecEncode(secretKey)); + await page.getByLabel("Password", { exact: true }).fill("web-agent-config"); + await page.getByLabel("Confirm password").fill("web-agent-config"); + await page.getByRole("button", { name: "Sign in with recovery key" }).click(); + + await expect(page.getByText("Sylar", { exact: true }).first()).toBeVisible(); + await expect( + page.getByText("Workspace Agent 7", { exact: true }), + ).toHaveCount(0); + + const composer = page.getByLabel("Message general"); + await composer.fill("@Sylar check the deployment"); + await composer.press("Enter"); + await expect + .poll(() => + page.evaluate(() => + ( + window as typeof window & { + __BUZZ_WEB_E2E_PUBLISHED__: Array<{ + kind: number; + tags: string[][]; + }>; + } + ).__BUZZ_WEB_E2E_PUBLISHED__.filter( + (relayEvent) => relayEvent.kind === 9000 || relayEvent.kind === 9, + ), + ), + ) + .toEqual([ + expect.objectContaining({ + kind: 9000, + tags: [ + ["h", "general"], + ["p", agentPubkey], + ["role", "bot"], + ], + }), + expect.objectContaining({ + kind: 9, + tags: [ + ["h", "general"], + ["p", agentPubkey], + ], + }), + ]); +}); + test("an already-present personal agent remains mentionable", async ({ page, }) => { diff --git a/web/tests/workspace-inbox-policy.test.mjs b/web/tests/workspace-inbox-policy.test.mjs new file mode 100644 index 0000000000..d012a85d2b --- /dev/null +++ b/web/tests/workspace-inbox-policy.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + inboxDismissContextId, + isTargetedApprovalRequest, +} from "../src/features/workspace/workspace-inbox-policy.mjs"; + +const viewer = "a".repeat(64); + +test("only a pending approval request addressed to the viewer enters Inbox", () => { + assert.equal( + isTargetedApprovalRequest( + { kind: 46010, tags: [["p", viewer.toUpperCase()]] }, + viewer, + ), + true, + ); + assert.equal( + isTargetedApprovalRequest({ kind: 46010, tags: [] }, viewer), + false, + ); + assert.equal( + isTargetedApprovalRequest( + { kind: 46010, tags: [["p", "b".repeat(64)]] }, + viewer, + ), + false, + ); +}); + +test("mentions, terminal decisions, and approval-like prose stay out", () => { + for (const event of [ + { kind: 9, tags: [["p", viewer]], content: "@Varun" }, + { kind: 9, tags: [], content: "waiting for your approval" }, + { kind: 46011, tags: [["p", viewer]], content: "granted" }, + { kind: 46012, tags: [["p", viewer]], content: "denied" }, + ]) { + assert.equal(isTargetedApprovalRequest(event, viewer), false); + } +}); + +test("dismiss markers are per approval event", () => { + assert.equal(inboxDismissContextId("event-1"), "inbox-dismiss:event-1"); +});