From 5e69a11a376e9386c14f049785f96592551a26ab Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 22:23:21 -0700 Subject: [PATCH 01/35] [agents2] preserve picture and description in ordinary profiles Store optional picture and about fields on Agents2 identities and publish them with the name. Track accepted metadata so updates and clears publish even without a rename. Preserve compatibility with older rows and publication receipts. Implemented with goose AI assistance. Human acceptance: green confirmed working. Signed-off-by: Bradley Axen --- crates/agent-controller/src/app_agent.rs | 27 +++++++- .../agent-controller/src/app_agent/tests.rs | 67 +++++++++++++++++++ src-tauri/src/app_agents.rs | 10 ++- 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/crates/agent-controller/src/app_agent.rs b/crates/agent-controller/src/app_agent.rs index e07c14580..7b5e0f134 100644 --- a/crates/agent-controller/src/app_agent.rs +++ b/crates/agent-controller/src/app_agent.rs @@ -31,6 +31,11 @@ pub struct AppAgent { pub agent_type: String, #[serde(default)] pub name: String, + /// Public profile metadata, published as kind-0 picture and about. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub picture: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub about: Option, /// The last profile its community accepted. #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, @@ -43,6 +48,10 @@ pub struct AppAgent { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Published { pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub picture: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub about: Option, pub created_at: u64, } impl AppAgent { @@ -90,11 +99,23 @@ impl AppAgent { let tags = self.attested(key, tags)?; key.signed(kind, content, tags) } + /// Whether the community accepted this row's current public metadata. + pub fn profile_is_current(&self) -> bool { + self.profile.as_ref().is_some_and(|last| { + last.name == self.name && last.picture == self.picture && last.about == self.about + }) + } /// Its profile as this row describes it, replacing the last one accepted. - /// No other writer exists: only this device holds the key. pub fn profile_event(&self, key: &Secret) -> Result { valid_name(&self.name)?; - let content = serde_json::json!({ "name": self.name, "bot": true }).to_string(); + let mut content = serde_json::json!({ "name": self.name, "bot": true }); + if let Some(picture) = &self.picture { + content["picture"] = picture.as_str().into(); + } + if let Some(about) = &self.about { + content["about"] = about.as_str().into(); + } + let content = content.to_string(); let tags = self.attested(key, vec![])?; key.sign_event_after( 0, @@ -299,6 +320,8 @@ impl AppAgents { auth, agent_type: prepared.agent_type, name: prepared.name, + picture: None, + about: None, profile: None, deleted: false, }; diff --git a/crates/agent-controller/src/app_agent/tests.rs b/crates/agent-controller/src/app_agent/tests.rs index 219ee8a2d..2b4518878 100644 --- a/crates/agent-controller/src/app_agent/tests.rs +++ b/crates/agent-controller/src/app_agent/tests.rs @@ -122,6 +122,8 @@ fn renames_and_profiles_replace_the_last_accepted_profile() { let future = first["created_at"].as_u64().unwrap() + 100; let published = |created_at| Published { name: "Ada".into(), + picture: None, + about: None, created_at, }; agents.published(&agent.pubkey, published(future)).unwrap(); @@ -324,3 +326,68 @@ fn concurrent_writers_on_one_file_keep_every_identity() { saved.sort(); assert_eq!(saved, made); } + +#[test] +fn saved_public_profile_survives_updates_and_is_signed() { + let dir = tempfile::tempdir().unwrap(); + let credentials = Memory::default(); + let (agents, agent) = created(dir.path(), &credentials); + let mut saved: serde_json::Value = + serde_json::from_slice(&std::fs::read(&agents.path).unwrap()).unwrap(); + saved[0]["picture"] = "https://relay.example/media/a.png".into(); + saved[0]["about"] = "Reviews pull requests.\nKeeps changes small.".into(); + // Existing receipts predate picture/about tracking. + saved[0]["profile"] = serde_json::json!({"name": "Ada", "createdAt": 500}); + std::fs::write(&agents.path, saved.to_string()).unwrap(); + agents.rename(&agent.pubkey, "Bea").unwrap(); + let reopened = AppAgents::open(agents.path.clone()); + let agent = reopened.get(&agent.pubkey).unwrap(); + let event = agent + .profile_event(&agent.read_key(&credentials).unwrap()) + .unwrap(); + let content: serde_json::Value = + serde_json::from_str(event["content"].as_str().unwrap()).unwrap(); + assert_eq!( + content, + serde_json::json!({ + "name": "Bea", "bot": true, + "picture": "https://relay.example/media/a.png", + "about": "Reviews pull requests.\nKeeps changes small.", + }) + ); + assert!(!agent.profile_is_current()); + reopened + .published( + &agent.pubkey, + Published { + name: agent.name.clone(), + picture: agent.picture.clone(), + about: agent.about.clone(), + created_at: event["created_at"].as_u64().unwrap(), + }, + ) + .unwrap(); + assert!(reopened.get(&agent.pubkey).unwrap().profile_is_current()); + let accepted = std::fs::read(&reopened.path).unwrap(); + // Each metadata-only change/clear must publish, even without a rename. + for field in ["picture", "about"] { + for replacement in [Some("https://relay.example/changed"), None] { + let mut changed: serde_json::Value = serde_json::from_slice(&accepted).unwrap(); + changed[0][field] = serde_json::json!(replacement); + std::fs::write(&reopened.path, changed.to_string()).unwrap(); + let changed = reopened.get(&agent.pubkey).unwrap(); + assert!(!changed.profile_is_current()); + let event = changed + .profile_event(&agent.read_key(&credentials).unwrap()) + .unwrap(); + let content: serde_json::Value = + serde_json::from_str(event["content"].as_str().unwrap()).unwrap(); + assert_eq!( + content.get(field).and_then(serde_json::Value::as_str), + replacement + ); + } + } + let event: nostr::event::Event = serde_json::from_value(event).unwrap(); + event.verify().unwrap(); +} diff --git a/src-tauri/src/app_agents.rs b/src-tauri/src/app_agents.rs index 9fc68db66..049466bd1 100644 --- a/src-tauri/src/app_agents.rs +++ b/src-tauri/src/app_agents.rs @@ -336,8 +336,8 @@ pub(crate) async fn app_agent_publish( post(&agent, &key, signed).await } -/// Publishes the agent's profile if its name differs from the last one its -/// community accepted. Safe to call any time; one runs per agent at a time. +/// Publishes changed public metadata. Safe to call any time; one publication +/// runs per agent at a time and records only what its community accepted. #[tauri::command] pub(crate) async fn app_agent_publish_profile( state: tauri::State<'_, AppAgentHost>, @@ -346,7 +346,7 @@ pub(crate) async fn app_agent_publish_profile( let turn = state.profile_turn(&pubkey); let _turn = turn.lock().await; let agent = state.agent(pubkey).await?; - if agent.profile.as_ref().map(|last| &last.name) == Some(&agent.name) { + if agent.profile_is_current() { return Ok(()); } let (agent, key) = state.key(agent).await?; @@ -361,6 +361,8 @@ pub(crate) async fn app_agent_publish_profile( &agent.pubkey, Published { name: agent.name, + picture: agent.picture, + about: agent.about, created_at, }, ) @@ -590,6 +592,8 @@ mod tests { auth: "[]".into(), agent_type: "p/t".into(), name: "Ada".into(), + picture: None, + about: None, profile: None, deleted: false, }; From 089288c2e93f1b1811840ce36c382230cf79d0d7 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 16:08:07 -0700 Subject: [PATCH 02/35] [agents2-adopt] bring over agents from earlier agent systems Agents2 can now take over an identity that the harness controller or old Buzz (installed or dev) saved on this machine. It keeps the key, name and picture, and leaves the instructions and settings behind. Neither old store is written. - port.rs lists both stores as one flat list, one entry per key. The harness copy wins because its key is already in this app's store. It also re-reads an identity and its key at take-over time. - AppAgents::adopt is prepare with an existing key. When a harness agent saved for the same community already holds the key under the same credential ID, commit shares that key and never adds or withdraws it. Without this, a failed add would have deleted the harness copy. - An adopted agent keeps the profile its community already has. Without one, it publishes the name and picture it brought. If the read fails, it overwrites nothing until the agent is renamed. Agents2 profiles gain an optional picture. - Adds a "Bring over" side panel on the Agents2 page. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen --- crates/agent-controller/src/app_agent.rs | 62 +++++- .../agent-controller/src/app_agent/tests.rs | 94 +++++++++ crates/agent-controller/src/import.rs | 2 +- crates/agent-controller/src/lib.rs | 2 + crates/agent-controller/src/port.rs | 175 +++++++++++++++++ crates/agent-controller/src/port/tests.rs | 175 +++++++++++++++++ src-tauri/build.rs | 2 + src-tauri/capabilities/default.json | 2 + src-tauri/src/app_agents.rs | 128 +++++++++++- src-tauri/src/browser_permissions_tests.rs | 2 + src-tauri/src/lib.rs | 9 + src/bundled/agents2/Agents2Page.module.css | 16 ++ src/bundled/agents2/Agents2Page.test.tsx | 54 ++++++ src/bundled/agents2/Agents2Page.tsx | 183 ++++++++++++++++-- src/bundled/builderlab/index.test.tsx | 2 + src/features/agents2/native.ts | 36 ++++ src/features/agents2/service.test.ts | 66 +++++++ src/features/agents2/service.ts | 57 +++++- 18 files changed, 1043 insertions(+), 24 deletions(-) create mode 100644 crates/agent-controller/src/port.rs create mode 100644 crates/agent-controller/src/port/tests.rs diff --git a/crates/agent-controller/src/app_agent.rs b/crates/agent-controller/src/app_agent.rs index 7b5e0f134..cb61d6a86 100644 --- a/crates/agent-controller/src/app_agent.rs +++ b/crates/agent-controller/src/app_agent.rs @@ -192,13 +192,16 @@ impl AppAgent { } } -/// A generated key awaiting its owner attestation. Dropping it discards the key. +/// A key awaiting its owner attestation. Dropping a generated one discards it. pub struct NewAppAgent { key: Secret, relay: String, owner: String, agent_type: String, name: String, + picture: Option, + /// An earlier agent system's key, which may already be saved under this ID. + adopted: bool, } impl NewAppAgent { pub fn pubkey(&self) -> &str { @@ -280,6 +283,27 @@ impl AppAgents { owner: &str, agent_type: &str, name: &str, + ) -> Result { + let mut agent = Self::adopt( + destination, + owner, + agent_type, + Secret::generate()?, + name, + None, + )?; + agent.adopted = false; + Ok(agent) + } + /// As `prepare`, for a key an earlier agent system saved, so the agent keeps + /// its identity and profile picture. + pub fn adopt( + destination: &str, + owner: &str, + agent_type: &str, + key: Secret, + name: &str, + picture: Option, ) -> Result { if !canonical_key(owner) { return Err("Choose a signed-in owner".into()); @@ -289,10 +313,12 @@ impl AppAgents { } Ok(NewAppAgent { relay: canonical_relay(destination)?, - key: Secret::generate()?, + key, owner: owner.into(), agent_type: agent_type.into(), name: valid_name(name)?, + picture: picture.filter(|url| crate::port::public_picture(url)), + adopted: true, }) } /// Saves the identity, then the key, so a saved key always has an identity to @@ -320,17 +346,23 @@ impl AppAgents { auth, agent_type: prepared.agent_type, name: prepared.name, - picture: None, + picture: prepared.picture, about: None, profile: None, deleted: false, }; let id = agent.credential_id(); + // A harness agent saved for the same community keeps this key under the + // same ID. It is shared, never added again or withdrawn on failure here. + let shared = prepared.adopted && credentials.read(&id, &agent.pubkey)?.is_some(); self.update(|agents| { agents.retain(|saved| saved.pubkey != agent.pubkey); agents.push(agent.clone()); Ok(()) })?; + if shared { + return Ok((agent, prepared.key)); + } let saved = credentials.add(&id, &prepared.key).and_then(|()| { agent .read_key(credentials) @@ -364,6 +396,30 @@ impl AppAgents { } }) } + /// Takes the profile its community already has for an adopted key as the + /// last one accepted, so the agent keeps that name and picture and nothing + /// is republished until it is renamed here. A name the app could not + /// publish leaves the row as it was. + pub fn keep_profile( + &self, + pubkey: &str, + name: &str, + picture: Option, + created_at: u64, + ) -> Result<()> { + let name = valid_name(name)?; + let picture = picture.filter(|url| crate::port::public_picture(url)); + self.edit(pubkey, |agent| { + agent.profile = Some(Published { + name: name.clone(), + created_at, + }); + agent.name = name; + if picture.is_some() { + agent.picture = picture; + } + }) + } /// Deletes the key, then marks the row deleted, so a failed deletion can be /// retried. Absence is success. pub fn remove(&self, pubkey: &str, credentials: &dyn Credentials) -> Result<()> { diff --git a/crates/agent-controller/src/app_agent/tests.rs b/crates/agent-controller/src/app_agent/tests.rs index 2b4518878..923ab0ed7 100644 --- a/crates/agent-controller/src/app_agent/tests.rs +++ b/crates/agent-controller/src/app_agent/tests.rs @@ -391,3 +391,97 @@ fn saved_public_profile_survives_updates_and_is_signed() { let event: nostr::event::Event = serde_json::from_value(event).unwrap(); event.verify().unwrap(); } + +#[test] +fn an_adopted_key_keeps_its_identity_picture_and_shared_saved_key() { + let dir = tempfile::tempdir().unwrap(); + let credentials = Memory::default(); + let key = Secret::generate().unwrap(); + let pubkey = key.pubkey().to_owned(); + // A harness agent already keeps this key under the same community's ID. + let id = agent_id(&pubkey, "wss://relay.example"); + credentials.add(&id, &key).unwrap(); + set(&credentials.1.add, true); + let agents = AppAgents::open(dir.path().join("agents2/identities.json")); + let adopt = |picture: &str| { + AppAgents::adopt( + "https://relay.example/", + &owner(), + "p/t", + Secret::parse(&key.hex(), &pubkey).unwrap(), + "Ada", + Some(picture.into()), + ) + .unwrap() + }; + let auth: Vec = + serde_json::from_str(&crate::secret::test_attestation(&pubkey)).unwrap(); + let (agent, _) = agents + .commit( + adopt("https://relay.example/media/a.png"), + &auth, + &credentials, + ) + .unwrap(); + assert_eq!(agent.pubkey, pubkey); + let profile = agent + .profile_event(&agent.read_key(&credentials).unwrap()) + .unwrap(); + assert_eq!( + profile["content"], + r#"{"bot":true,"name":"Ada","picture":"https://relay.example/media/a.png"}"# + ); + // A shared key that fails to verify is left where it was. + set(&credentials.1.read, true); + assert!(agents + .commit(adopt("https://a.example/b.png"), &auth, &credentials) + .is_err()); + set(&credentials.1.read, false); + assert!(credentials.read(&id, &pubkey).unwrap().is_some()); + // Inline pictures are not profile pictures. + let (agent, _) = agents + .commit(adopt("data:image/png;base64,AA"), &auth, &credentials) + .unwrap(); + assert_eq!(agent.picture, None); +} + +#[test] +fn keeps_the_communitys_profile_for_an_adopted_key_until_renamed() { + let dir = tempfile::tempdir().unwrap(); + let credentials = Memory::default(); + let (agents, agent) = created(dir.path(), &credentials); + agents + .keep_profile( + &agent.pubkey, + " Old Ada ", + Some("https://a.example/p.png".into()), + 500, + ) + .unwrap(); + let kept = agents.get(&agent.pubkey).unwrap(); + assert_eq!(kept.name, "Old Ada"); + assert_eq!(kept.picture.as_deref(), Some("https://a.example/p.png")); + // Nothing to republish: the accepted profile already has this name. + assert_eq!( + kept.profile, + Some(Published { + name: "Old Ada".into(), + created_at: 500 + }) + ); + // No usable picture keeps the one it has; a rename republishes it, newer. + agents + .keep_profile(&agent.pubkey, "Old Ada", Some("data:,x".into()), 500) + .unwrap(); + agents.rename(&agent.pubkey, "Ada").unwrap(); + let renamed = agents.get(&agent.pubkey).unwrap(); + let next = renamed + .profile_event(&renamed.read_key(&credentials).unwrap()) + .unwrap(); + assert_eq!( + next["content"], + r#"{"bot":true,"name":"Ada","picture":"https://a.example/p.png"}"# + ); + assert!(next["created_at"].as_u64().unwrap() > 500); + assert!(agents.keep_profile(&agent.pubkey, " ", None, 1).is_err()); +} diff --git a/crates/agent-controller/src/import.rs b/crates/agent-controller/src/import.rs index ce9c7ec2e..367a956ea 100644 --- a/crates/agent-controller/src/import.rs +++ b/crates/agent-controller/src/import.rs @@ -560,7 +560,7 @@ fn read_source(root: &Path) -> Result { digest: format!("{:x}", Sha256::digest(bytes)), }) } -fn read_json(path: &Path, optional: bool) -> Result { +pub(crate) fn read_json(path: &Path, optional: bool) -> Result { let meta = match fs::symlink_metadata(path) { Err(e) if optional && e.kind() == std::io::ErrorKind::NotFound => return Ok(json!({})), Err(_) => return Err("Could not read chosen Buzz source".into()), diff --git a/crates/agent-controller/src/lib.rs b/crates/agent-controller/src/lib.rs index cd74797ed..2a1af0d0b 100644 --- a/crates/agent-controller/src/lib.rs +++ b/crates/agent-controller/src/lib.rs @@ -21,6 +21,7 @@ mod import; pub mod logs; mod ownership; pub mod pi; +mod port; mod process; mod profile; mod restart; @@ -52,6 +53,7 @@ pub use harness_presets::{harness_preset, harness_presets, HarnessPreset}; pub use import::{ CloneSettings, CredentialedImport, ImportPreview, Imports, LegacySource, PreparedImport, }; +pub use port::{OldStores, PortSource, Portable, Portables}; pub use restart::{RestartChange, RestartDiffEntry}; pub use runtime::path::{prepare_tools_path, tools_path, warm_tools_path}; pub use runtime::{ diff --git a/crates/agent-controller/src/port.rs b/crates/agent-controller/src/port.rs new file mode 100644 index 000000000..0ec074372 --- /dev/null +++ b/crates/agent-controller/src/port.rs @@ -0,0 +1,175 @@ +//! Agent identities an earlier agent system saved on this machine, which Agents2 +//! can take over: the harness controller's library and old Buzz's. Only the +//! identity and its public name and picture move; instructions, harness settings +//! and memberships stay behind. Neither store is ever written. +use crate::config::{agent_id, canonical_key, canonical_relay, MAX_AGENTS}; +use crate::import::read_json; +use crate::{Credentials, LegacySource, Result, Secret}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; +use std::path::PathBuf; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PortSource { + /// This app's harness agents, whose keys are already in its credential store. + Harness, + Installed, + Development, +} +impl PortSource { + fn legacy(self) -> Option { + match self { + Self::Harness => None, + Self::Installed => Some(LegacySource::Installed), + Self::Development => Some(LegacySource::Development), + } + } +} + +/// One identity another store holds, as it was last saved there. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Portable { + pub source: PortSource, + pub pubkey: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub picture: Option, + /// The community it was saved for there; the new identity uses the open one. + pub relay: String, +} + +#[derive(Debug, Default, Serialize)] +pub struct Portables { + pub agents: Vec, + /// A store that could not be read; the others are still listed. + pub warnings: Vec, +} + +/// The harness library file and the OS data directory old Buzz kept its own in. +#[derive(Clone)] +pub struct OldStores { + pub harness: PathBuf, + pub data_dir: PathBuf, +} +impl OldStores { + /// Every identity on this machine, one per key. The harness copy wins, since + /// reading its key needs no other app's keychain entry. A missing store adds + /// nothing. + pub fn list(&self) -> Portables { + let mut found = Portables::default(); + let mut seen = BTreeSet::new(); + for source in [ + PortSource::Harness, + PortSource::Installed, + PortSource::Development, + ] { + match self.read(source) { + Ok(agents) => found.agents.extend( + agents + .into_iter() + .map(|(agent, _)| agent) + .filter(|agent| seen.insert(agent.pubkey.clone())), + ), + Err(error) => found.warnings.push(error), + } + } + found + } + /// One listed identity and its key, both re-read from the store it is in, so + /// the caller names only its choice. + pub fn take( + &self, + source: PortSource, + pubkey: &str, + credentials: &dyn Credentials, + ) -> Result<(Portable, Secret)> { + let (agent, credential) = self + .read(source)? + .into_iter() + .find(|(agent, _)| agent.pubkey == pubkey) + .ok_or("That agent is no longer saved there")?; + let key = match source.legacy() { + Some(legacy) => credentials.read_legacy(legacy, pubkey)?, + None => credentials + .read(&credential, pubkey)? + .ok_or("That agent's key is no longer saved")?, + }; + Ok((agent, key)) + } + /// Its identities, each with the harness credential ID (empty for old Buzz). + fn read(&self, source: PortSource) -> Result> { + let (path, records) = match source.legacy() { + None => (self.harness.clone(), "agents"), + Some(legacy) => ( + self.data_dir + .join(legacy.app_directory()) + .join("agents/managed-agents.json"), + "", + ), + }; + if !path.try_exists().unwrap_or(true) { + return Ok(vec![]); + } + let file = + read_json(&path, false).map_err(|_| format!("Could not read {}", path.display()))?; + let records = if records.is_empty() { + &file + } else { + &file[records] + }; + let records = records + .as_array() + .filter(|records| records.len() <= MAX_AGENTS) + .ok_or_else(|| format!("Could not read {}", path.display()))?; + Ok(records + .iter() + .filter_map(|record| portable(source, record)) + .collect()) + } +} + +fn portable(source: PortSource, record: &Value) -> Option<(Portable, String)> { + let text = |key: &str| record.get(key).and_then(Value::as_str).unwrap_or(""); + let harness = source == PortSource::Harness; + let pubkey = text("pubkey"); + let relay = + canonical_relay(text(if harness { "relayUrl" } else { "relay_url" })).unwrap_or_default(); + let name = text("name").trim(); + if !canonical_key(pubkey) || name.is_empty() { + return None; + } + let credential = if harness { + Some(text("credentialId")) + .filter(|id| !id.is_empty()) + .map_or_else(|| agent_id(pubkey, &relay), str::to_owned) + } else { + String::new() + }; + let picture = Some(text(if harness { "picture" } else { "avatar_url" })) + .filter(|url| public_picture(url)) + .map(str::to_owned); + Some(( + Portable { + source, + pubkey: pubkey.into(), + name: name.into(), + picture, + relay, + }, + credential, + )) +} + +/// A profile picture is a public HTTPS URL of bounded length, never inline data. +pub(crate) fn public_picture(url: &str) -> bool { + url.len() <= 2048 + && url::Url::parse(url).is_ok_and(|url| { + url.scheme() == "https" && url.username().is_empty() && url.password().is_none() + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/agent-controller/src/port/tests.rs b/crates/agent-controller/src/port/tests.rs new file mode 100644 index 000000000..89db33356 --- /dev/null +++ b/crates/agent-controller/src/port/tests.rs @@ -0,0 +1,175 @@ +use super::*; +use serde_json::json; +use std::collections::BTreeMap; +use std::sync::Mutex; + +/// Saved keys by credential ID, and old Buzz's by source and pubkey. +#[derive(Default)] +struct Memory(Mutex>); +impl Credentials for Memory { + fn read_legacy(&self, source: LegacySource, pubkey: &str) -> Result { + let saved = self.0.lock().unwrap(); + let hex = saved + .get(&format!("{}:{pubkey}", source.keyring_service())) + .ok_or("Absent")?; + Secret::parse(hex, pubkey) + } + fn read(&self, id: &str, pubkey: &str) -> Result> { + let saved = self.0.lock().unwrap(); + saved + .get(id) + .map(|hex| Secret::parse(hex, pubkey)) + .transpose() + } + fn add(&self, id: &str, key: &Secret) -> Result<()> { + let mut saved = self.0.lock().unwrap(); + saved.insert(id.into(), key.hex().to_string()); + Ok(()) + } + fn delete(&self, id: &str, _: &str) -> Result<()> { + self.0.lock().unwrap().remove(id); + Ok(()) + } +} + +fn stores(dir: &std::path::Path) -> OldStores { + OldStores { + harness: dir.join("app/agent-controller/agents.json"), + data_dir: dir.join("data"), + } +} +fn write(path: std::path::PathBuf, value: Value) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, value.to_string()).unwrap(); +} +fn legacy(dir: &std::path::Path, source: LegacySource, value: Value) { + let path = dir + .join("data") + .join(source.app_directory()) + .join("agents/managed-agents.json"); + write(path, value); +} +fn key() -> Secret { + Secret::generate().unwrap() +} + +#[test] +fn lists_each_identity_once_preferring_the_harness_copy() { + let dir = tempfile::tempdir().unwrap(); + let (shared, old, dev) = (key(), key(), key()); + write( + stores(dir.path()).harness, + json!({"version": 1, "agents": [{ + "pubkey": shared.pubkey(), "relayUrl": "wss://relay.example", + "name": "Ada", "picture": "data:image/png;base64,AA", + "credentialId": "ignored-by-list", + }]}), + ); + legacy( + dir.path(), + LegacySource::Installed, + json!([ + {"pubkey": shared.pubkey(), "name": "Ada (old)", "relay_url": "wss://old.example"}, + {"pubkey": old.pubkey(), "name": " Bea ", "relay_url": "wss://old.example/", + "avatar_url": "https://old.example/media/b.png"}, + // A persona with no identity, and an unnamed identity. + {"pubkey": "", "name": "Persona"}, + {"pubkey": key().pubkey(), "name": ""}, + ]), + ); + legacy( + dir.path(), + LegacySource::Development, + json!([{"pubkey": dev.pubkey(), "name": "Cy", "relay_url": "not a relay"}]), + ); + let found = stores(dir.path()).list(); + assert!(found.warnings.is_empty()); + assert_eq!( + found.agents, + [ + Portable { + source: PortSource::Harness, + pubkey: shared.pubkey().into(), + name: "Ada".into(), + picture: None, + relay: "wss://relay.example".into(), + }, + Portable { + source: PortSource::Installed, + pubkey: old.pubkey().into(), + name: "Bea".into(), + picture: Some("https://old.example/media/b.png".into()), + relay: "wss://old.example".into(), + }, + Portable { + source: PortSource::Development, + pubkey: dev.pubkey().into(), + name: "Cy".into(), + picture: None, + relay: String::new(), + }, + ] + ); +} + +#[test] +fn missing_stores_list_nothing_and_a_broken_one_hides_only_itself() { + let dir = tempfile::tempdir().unwrap(); + assert!(stores(dir.path()).list().agents.is_empty()); + assert!(stores(dir.path()).list().warnings.is_empty()); + let old = key(); + write(stores(dir.path()).harness, json!({"agents": "nope"})); + legacy( + dir.path(), + LegacySource::Installed, + json!([{"pubkey": old.pubkey(), "name": "Bea"}]), + ); + let found = stores(dir.path()).list(); + assert_eq!(found.warnings.len(), 1); + assert_eq!(found.agents.len(), 1); + assert_eq!(found.agents[0].pubkey, old.pubkey()); +} + +#[test] +fn takes_the_key_from_the_store_it_was_listed_in() { + let dir = tempfile::tempdir().unwrap(); + let credentials = Memory::default(); + let (harness, old) = (key(), key()); + let id = agent_id(harness.pubkey(), "wss://relay.example"); + credentials.add(&id, &harness).unwrap(); + credentials.0.lock().unwrap().insert( + format!("buzz-desktop:{}", old.pubkey()), + old.hex().to_string(), + ); + write( + stores(dir.path()).harness, + json!({"agents": [{ + "pubkey": harness.pubkey(), "relayUrl": "wss://relay.example", + "name": "Ada", "credentialId": id, + }]}), + ); + legacy( + dir.path(), + LegacySource::Installed, + json!([{"pubkey": old.pubkey(), "name": "Bea"}]), + ); + let stores = stores(dir.path()); + let (agent, key) = stores + .take(PortSource::Harness, harness.pubkey(), &credentials) + .unwrap(); + assert_eq!( + (agent.name.as_str(), key.pubkey()), + ("Ada", harness.pubkey()) + ); + let (agent, key) = stores + .take(PortSource::Installed, old.pubkey(), &credentials) + .unwrap(); + assert_eq!((agent.name.as_str(), key.pubkey()), ("Bea", old.pubkey())); + // Each source reads only its own store. + assert!(stores + .take(PortSource::Development, old.pubkey(), &credentials) + .is_err()); + assert!(stores + .take(PortSource::Harness, old.pubkey(), &credentials) + .is_err()); +} diff --git a/src-tauri/build.rs b/src-tauri/build.rs index ec00cd6b2..83dd36045 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -103,6 +103,8 @@ fn main() { "agent_control_create_prepare", "app_agent_list", "app_agent_create", + "app_agent_portable", + "app_agent_port", "app_agent_rename", "app_agent_delete", "app_agent_forget", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index fac28ed35..3d600b509 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -73,6 +73,8 @@ "allow-agent-control-create-prepare", "allow-app-agent-list", "allow-app-agent-create", + "allow-app-agent-portable", + "allow-app-agent-port", "allow-app-agent-rename", "allow-app-agent-delete", "allow-app-agent-forget", diff --git a/src-tauri/src/app_agents.rs b/src-tauri/src/app_agents.rs index 049466bd1..c8ccb3eb8 100644 --- a/src-tauri/src/app_agents.rs +++ b/src-tauri/src/app_agents.rs @@ -4,7 +4,8 @@ //! to the agent's community. use crate::agents::profile_http::authorization; use buzz_agent_controller::{ - AppAgent, AppAgents, Credentials, PlatformCredentials, Published, Secret, CREDENTIALS_BUSY, + AppAgent, AppAgents, Credentials, OldStores, PlatformCredentials, PortSource, Portables, + Published, Secret, CREDENTIALS_BUSY, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -22,6 +23,8 @@ const BUSY_WAIT: std::time::Duration = std::time::Duration::from_millis(100); #[derive(Clone)] pub(crate) struct AppAgentHost { agents: Result, + /// Earlier agent systems' identities on this machine, which it can take over. + old: Result, credentials: Arc, /// Each key after its first read, so only create and first use reach the /// credential store, whose lock refuses rather than waits. Waiting on this @@ -34,13 +37,18 @@ pub(crate) struct AppAgentHost { } impl AppAgentHost { /// `packaged` is false for a dev build, which steps aside for a packaged app. - pub(crate) fn new(path: Result, packaged: bool) -> Self { + pub(crate) fn new( + path: Result, + old: Result, + packaged: bool, + ) -> Self { let hosts = path .as_ref() .map_err(Clone::clone) .and_then(|path| Ok(path.parent().ok_or("Invalid agent storage")?.join("hosts"))); Self { agents: path.map(AppAgents::open), + old, credentials: Arc::new(PlatformCredentials::default()), keys: Arc::default(), profiles: Arc::default(), @@ -272,6 +280,109 @@ pub(crate) async fn app_agent_create( .await } +/// Identities earlier agent systems saved on this machine, as Agents2 could take +/// them over; the caller leaves out its own. +#[tauri::command] +pub(crate) async fn app_agent_portable( + state: tauri::State<'_, AppAgentHost>, +) -> Result { + let old = state.old.clone()?; + blocking(move || Ok(old.list())).await +} + +/// Takes over one identity an earlier agent system saved, keeping its key, name +/// and picture, as `app_agent_create` does for a new one. Its old store keeps it. +#[tauri::command] +pub(crate) async fn app_agent_port( + state: tauri::State<'_, AppAgentHost>, + identity: tauri::State<'_, crate::identity::IdentityHost>, + source: PortSource, + pubkey: String, + destination: String, + owner: String, + agent_type: String, +) -> Result { + let agents = state.agents.clone()?; + let old = state.old.clone()?; + let credentials = state.credentials.clone(); + let (found, key) = blocking(move || { + credentials.retry(); + old.take(source, &pubkey, credentials.as_ref()) + }) + .await?; + let prepared = AppAgents::adopt( + &destination, + &owner, + &agent_type, + key, + &found.name, + found.picture, + )?; + let auth = identity + .inner() + .authorize_agent(owner, prepared.pubkey().to_owned()) + .await?; + let host = state.inner().clone(); + let saved = agents.clone(); + let (agent, key) = blocking(move || { + let mut keys = host.keys(); + let (agent, key) = saved.commit(prepared, &auth, host.credentials.as_ref())?; + let key = Arc::new(key); + keys.insert(agent.pubkey.clone(), key.clone()); + Ok((agent, key)) + }) + .await?; + // Keep the profile its community already has; without one, the next sync + // publishes the name and picture it brought. If it cannot tell, it keeps + // whatever is there rather than overwrite it, until the agent is renamed. + let (name, picture, created_at) = match current_profile(&agent, &key).await { + Ok(None) => return Ok(agent.into()), + Ok(Some(profile)) => profile, + Err(_) => (agent.name.clone(), None, 0), + }; + let pubkey = agent.pubkey.clone(); + blocking(move || { + agents.keep_profile(&pubkey, &name, picture, created_at)?; + Ok(agents.get(&pubkey)?.into()) + }) + .await +} + +/// The newest profile (kind 0) its community has for the agent: its name, +/// picture and `created_at`. +async fn current_profile( + agent: &AppAgent, + key: &Secret, +) -> Result, u64)>, String> { + let filters = serde_json::json!([{ "kinds": [0], "authors": [agent.pubkey], "limit": 1 }]); + let events = read(agent, key, filters.as_array().ok_or("Invalid agent read")?).await?; + let Some(event) = events.as_array().and_then(|events| { + events + .iter() + .filter(|event| event["pubkey"] == agent.pubkey.as_str() && event["kind"] == 0) + .max_by_key(|event| event["created_at"].as_u64()) + }) else { + return Ok(None); + }; + let content: Value = serde_json::from_str(event["content"].as_str().unwrap_or("")) + .map_err(|_| "Invalid agent profile")?; + let name = ["display_name", "name"] + .iter() + .find_map(|field| { + content[field] + .as_str() + .filter(|name| !name.trim().is_empty()) + }) + .ok_or("Agent profile has no name")?; + Ok(Some(( + name.to_owned(), + content["picture"].as_str().map(str::to_owned), + event["created_at"] + .as_u64() + .ok_or("Invalid agent profile")?, + ))) +} + #[tauri::command] pub(crate) async fn app_agent_rename( state: tauri::State<'_, AppAgentHost>, @@ -382,7 +493,11 @@ pub(crate) async fn app_agent_query( return Err("Agents read with one to eight filters".into()); } let (agent, key) = state.key(state.agent(pubkey).await?).await?; - let bytes = serde_json::to_vec(&filters).map_err(|_| "Could not encode agent read")?; + read(&agent, &key, &filters).await +} + +async fn read(agent: &AppAgent, key: &Secret, filters: &[Value]) -> Result { + let bytes = serde_json::to_vec(filters).map_err(|_| "Could not encode agent read")?; let response = client(20)? .post(agent.query_url()) .header("Content-Type", "application/json") @@ -580,6 +695,7 @@ mod tests { let key = Secret::generate().unwrap(); let host = AppAgentHost { agents: Err("unused".into()), + old: Err("unused".into()), credentials: Arc::new(Busy(key.hex().to_string(), AtomicU32::new(3))), keys: Arc::default(), profiles: Arc::default(), @@ -604,7 +720,11 @@ mod tests { #[tokio::test] async fn delete_waits_for_a_profile_publication_in_flight() { let dir = tempfile::tempdir().unwrap(); - let host = AppAgentHost::new(Ok(dir.path().join("identities.json")), true); + let host = AppAgentHost::new( + Ok(dir.path().join("identities.json")), + Err("unused".into()), + true, + ); let pubkey = "c".repeat(64); let turn = host.profile_turn(&pubkey); let publishing = turn.lock().await; diff --git a/src-tauri/src/browser_permissions_tests.rs b/src-tauri/src/browser_permissions_tests.rs index b9c5add18..d794e38ec 100644 --- a/src-tauri/src/browser_permissions_tests.rs +++ b/src-tauri/src/browser_permissions_tests.rs @@ -120,6 +120,8 @@ fn native_command_permissions_allow_only_main_webview() { "agent_control_create_prepare", "app_agent_list", "app_agent_create", + "app_agent_portable", + "app_agent_port", "app_agent_rename", "app_agent_delete", "app_agent_forget", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5e2d6fc75..5740ce4dd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -504,6 +504,8 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen agent_control_create_prepare, app_agents::app_agent_list, app_agents::app_agent_create, + app_agents::app_agent_portable, + app_agents::app_agent_port, app_agents::app_agent_rename, app_agents::app_agent_delete, app_agents::app_agent_forget, @@ -673,6 +675,13 @@ pub fn run() { .as_ref() .map(|(root, _, _)| root.with_file_name("agents2").join("identities.json")) .map_err(Clone::clone), + paths + .as_ref() + .map(|(root, legacy, _)| buzz_agent_controller::OldStores { + harness: root.join("agents.json"), + data_dir: legacy.clone(), + }) + .map_err(Clone::clone), // `just desktop` builds step aside for a packaged app. !tauri::is_dev(), )); diff --git a/src/bundled/agents2/Agents2Page.module.css b/src/bundled/agents2/Agents2Page.module.css index eedc80421..f476b3c32 100644 --- a/src/bundled/agents2/Agents2Page.module.css +++ b/src/bundled/agents2/Agents2Page.module.css @@ -32,6 +32,22 @@ padding: var(--space-4) var(--space-5) var(--space-6); } +.portList { + display: grid; + margin: 0; + padding: 0; + list-style: none; +} +.portRow { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) 0; +} +.portRow + .portRow { + border-top: 1px solid var(--border-standard); +} + .grid { display: grid; /* Fixed columns, so opening the side panel never resizes a tile. */ diff --git a/src/bundled/agents2/Agents2Page.test.tsx b/src/bundled/agents2/Agents2Page.test.tsx index 3eb719e8a..e7d166814 100644 --- a/src/bundled/agents2/Agents2Page.test.tsx +++ b/src/bundled/agents2/Agents2Page.test.tsx @@ -11,6 +11,7 @@ import type { AgentsSnapshot, RegisteredAgentType, } from "../../features/agents2/service"; +import type { PortableAgent } from "../../features/agents2/native"; import type { RelayData } from "../../features/relay/service"; import { Agents2Page } from "./Agents2Page"; @@ -48,6 +49,22 @@ const agent = (name: string, n: number): Agent => ({ }, }); +const portable: PortableAgent[] = [ + { + source: "harness", + pubkey: "a".repeat(64), + name: "Ada", + relay: "wss://relay.example", + }, + { + source: "installed", + pubkey: "b".repeat(64), + name: "Bea", + picture: "https://old.example/media/b.png", + relay: "", + }, +]; + function setup(agents: Agent[], { shown = true } = {}) { let snapshot: AgentsSnapshot = { status: "ready", agents, running: agents }; const listeners = new Set<() => void>(); @@ -82,6 +99,8 @@ function setup(agents: Agent[], { shown = true } = {}) { for (const listener of listeners) listener(); return created; }), + portable: vi.fn(async () => ({ agents: portable, warnings: [] })), + port: vi.fn(async () => created), save: vi.fn(async () => {}), remove: vi.fn(async () => {}), } satisfies Agents2; @@ -185,3 +204,38 @@ it("saves a rename on Enter and discards it on Escape", async () => { name: "Kept", }); }); + +it("brings over an earlier agent as the chosen type and drops it from the list", async () => { + const user = userEvent.setup(); + const { agents2 } = setup([agent("Ada", 1)]); + await user.click(screen.getByRole("button", { name: "Bring over" })); + const list = await screen.findByRole("list", { + name: "Agents to bring over", + }); + expect( + within(list).getByText(/Buzz 1\.0 harness agent · npub/), + ).toBeInTheDocument(); + expect(within(list).getByText(/Buzz 0\.X · npub/)).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Bring over Bea" })); + expect(agents2.port).toHaveBeenCalledWith({ + source: "installed", + pubkey: "b".repeat(64), + type: "test/echo", + }); + expect(within(list).queryByText("Bea")).not.toBeInTheDocument(); + expect(within(list).getByText("Ada")).toBeInTheDocument(); +}); + +it("shows why bringing an agent over failed and keeps it listed", async () => { + const user = userEvent.setup(); + const { agents2 } = setup([]); + agents2.port.mockRejectedValueOnce(new Error("Keychain access was denied")); + await user.click(screen.getByRole("button", { name: "Bring over" })); + await user.click( + await screen.findByRole("button", { name: "Bring over Ada" }), + ); + expect(screen.getByRole("alert")).toHaveTextContent( + "Keychain access was denied", + ); + expect(screen.getByRole("button", { name: "Bring over Ada" })).toBeEnabled(); +}); diff --git a/src/bundled/agents2/Agents2Page.tsx b/src/bundled/agents2/Agents2Page.tsx index 02f7ebaaa..8f98cb68c 100644 --- a/src/bundled/agents2/Agents2Page.tsx +++ b/src/bundled/agents2/Agents2Page.tsx @@ -4,7 +4,8 @@ // - Peek: identity, the type's own read-only glance, and what wakes it. // - Build: the identity rail on the left, the type's tabs and the app's // Attention tab on the right. -// New agents are made in the peek slot, so making one never covers the grid. +// New agents are made in the peek slot, so making one never covers the grid, +// and so are agents brought over from an earlier agent system. import { useEffect, useRef, @@ -18,6 +19,11 @@ import type { ChannelChoice } from "../../features/agents2/attention"; import { AgentDelete } from "../../features/agents2/AgentManage"; import { AttentionSummary } from "../../features/agents2/AttentionPanel"; import { useAgents2, useChannelChoices } from "../../features/agents2/react"; +import type { + PortableAgent, + PortableAgents, + PortSource, +} from "../../features/agents2/native"; import type { Agent, Agents2, @@ -40,13 +46,20 @@ import { IconButton } from "../../shared/design-system/ui/IconButton"; import { Input } from "../../shared/design-system/ui/Input"; import { PanelHeader } from "../../shared/design-system/ui/PanelHeader"; import { Radio, RadioGroup } from "../../shared/design-system/ui/RadioGroup"; -import { formatPublicKey } from "../../shared/identity/public-key"; +import { + formatPublicKey, + publicKeyLabels, +} from "../../shared/identity/public-key"; const message = (error: unknown) => error instanceof Error ? error.message : String(error); type Frame = - | { view: "browse"; selected?: string; creating?: boolean } + | { + view: "browse"; + selected?: string; + side?: "creating" | "porting"; + } | { view: "build"; pubkey: string }; /** The type's one-line summary of an agent; its title when it has none. */ @@ -78,11 +91,13 @@ export function Agents2Page({ snapshot.agents.find((agent) => agent.pubkey === pubkey); const building = frame.view === "build" ? find(frame.pubkey) : undefined; const selected = frame.view === "browse" ? find(frame.selected) : undefined; - const creating = frame.view === "browse" && !!frame.creating; + const side = frame.view === "browse" ? frame.side : undefined; + const creating = side === "creating"; const browse = (selected?: string) => setFrame(selected ? { view: "browse", selected } : { view: "browse" }); const build = (pubkey: string) => setFrame({ view: "build", pubkey }); - const newAgent = () => setFrame({ view: "browse", creating: true }); + const newAgent = () => setFrame({ view: "browse", side: "creating" }); + const bringOver = () => setFrame({ view: "browse", side: "porting" }); const body = (() => { if (snapshot.status === "unavailable") @@ -112,13 +127,15 @@ export function Agents2Page({ onRemoved={() => browse()} /> ); - const side = creating ? ( + const panel = creating ? ( browse()} onCreated={(agent) => build(agent.pubkey)} /> + ) : side === "porting" ? ( + browse()} /> ) : selected ? ( ) : undefined; return ( -
+
{snapshot.agents.length ? ( - ) : snapshot.status === "ready" && !creating ? ( + ) : snapshot.status === "ready" && !side ? ( ) : null}
- {side && } + {panel && }
); })(); @@ -186,9 +203,18 @@ export function Agents2Page({ title={building ? building.name : "Agents2"} actions={ !building && types.length > 0 && snapshot.status === "ready" ? ( - +
+ + +
) : undefined } /> @@ -457,6 +483,139 @@ function NewAgent({ ); } +const SOURCES: Record = { + harness: "Buzz 1.0 harness agent", + installed: "Buzz 0.X", + development: "Buzz 0.X dev", +}; +const host = (relay: string) => relay.replace(/^wss:\/\//, ""); + +/** Identities earlier agent systems saved on this machine, each brought over + * with its key, name and picture as an agent of the chosen type. */ +function BringOver({ + agents2, + types, + onClose, +}: { + agents2: Agents2; + types: readonly RegisteredAgentType[]; + onClose(): void; +}) { + const [found, setFound] = useState(); + const [type, setType] = useState(types[0]?.key ?? ""); + const [pending, setPending] = useState(); + const [error, setError] = useState(""); + useEffect(() => { + let live = true; + agents2.portable().then( + (next) => live && setFound(next), + (reason) => live && setError(message(reason)), + ); + return () => { + live = false; + }; + }, [agents2]); + const chosen = types.find((item) => item.key === type) ?? types[0]; + const labels = publicKeyLabels(found?.agents.map((a) => a.pubkey) ?? []); + const port = async (agent: PortableAgent) => { + if (!chosen || pending) return; + setPending(agent.pubkey); + setError(""); + try { + await agents2.port({ + source: agent.source, + pubkey: agent.pubkey, + type: chosen.key, + }); + setFound( + (last) => + last && { + ...last, + agents: last.agents.filter((item) => item.pubkey !== agent.pubkey), + }, + ); + } catch (reason) { + setError(message(reason)); + } finally { + setPending(undefined); + } + }; + return ( +
+ Bring over agents} + onClose={onClose} + /> +

+ Keeps each agent's key, name and picture in this community. Its + instructions and settings stay behind. Stop it in old Buzz first, or + both will answer. +

+ + setType(String(value))} + > + {types.map((item) => ( + + ))} + + + {error && ( +

+ {error} +

+ )} + {found?.warnings.map((warning) => ( +

+ {warning} +

+ ))} + {!found ? ( + !error && ( +

+ Looking for agents… +

+ ) + ) : found.agents.length ? ( +
    + {found.agents.map((agent) => ( +
  • + + + {agent.name} + + {[ + SOURCES[agent.source], + labels.get(agent.pubkey), + agent.relay && host(agent.relay), + ] + .filter(Boolean) + .join(" · ")} + + + +
  • + ))} +
+ ) : ( +

+ No other agents are saved on this machine. +

+ )} +
+ ); +} + function AgentBuild({ agents2, agent, diff --git a/src/bundled/builderlab/index.test.tsx b/src/bundled/builderlab/index.test.tsx index c07d2ad43..b92951050 100644 --- a/src/bundled/builderlab/index.test.tsx +++ b/src/bundled/builderlab/index.test.tsx @@ -53,6 +53,8 @@ function provideAgents2(root: Context, relay: RelayData) { return new Agents2Service(root, communities, { list: async () => [], create: vi.fn(), + portable: vi.fn(), + port: vi.fn(), rename: vi.fn(), remove: vi.fn(), forget: vi.fn(), diff --git a/src/features/agents2/native.ts b/src/features/agents2/native.ts index 197e2d167..af98eb19c 100644 --- a/src/features/agents2/native.ts +++ b/src/features/agents2/native.ts @@ -15,6 +15,23 @@ export type AgentIdentity = Readonly<{ * archive it, after which `forget` removes it. */ deleted: boolean; }>; +/** Where an earlier agent system saved an identity on this machine: this app's + * harness agents, or the installed or development build of old Buzz. */ +export type PortSource = "harness" | "installed" | "development"; +/** An identity an earlier agent system saved, which Agents2 can take over. */ +export type PortableAgent = Readonly<{ + source: PortSource; + pubkey: string; + name: string; + picture?: string; + /** The community it was saved for there, if valid. */ + relay: string; +}>; +export type PortableAgents = Readonly<{ + agents: readonly PortableAgent[]; + /** A store that could not be read; the others are still listed. */ + warnings: readonly string[]; +}>; export type AgentEventTemplate = Readonly<{ kind: number; content: string; @@ -40,6 +57,16 @@ export type AgentsNative = { type: string, name: string, ): Promise; + /** Earlier agent systems' identities on this machine. */ + portable(): Promise; + /** Takes one over with its key, name and picture, as `create` does. */ + port( + source: PortSource, + pubkey: string, + destination: string, + owner: string, + type: string, + ): Promise; rename(pubkey: string, name: string): Promise; /** Deletes the key and marks the identity deleted. */ remove(pubkey: string): Promise; @@ -76,6 +103,15 @@ export const nativeAgents = (): AgentsNative | undefined => list: () => invoke("app_agent_list"), create: (destination, owner, agentType, name) => invoke("app_agent_create", { destination, owner, agentType, name }), + portable: () => invoke("app_agent_portable"), + port: (source, pubkey, destination, owner, agentType) => + invoke("app_agent_port", { + source, + pubkey, + destination, + owner, + agentType, + }), rename: (pubkey, name) => invoke("app_agent_rename", { pubkey, name }), remove: (pubkey) => invoke("app_agent_delete", { pubkey }), forget: (pubkey) => invoke("app_agent_forget", { pubkey }), diff --git a/src/features/agents2/service.test.ts b/src/features/agents2/service.test.ts index 93f2111f3..a3a210b33 100644 --- a/src/features/agents2/service.test.ts +++ b/src/features/agents2/service.test.ts @@ -183,6 +183,38 @@ function fakeNative(identities: AgentIdentity[] = []) { identities.push(identity); return identity; }), + portable: vi.fn(async () => ({ + agents: [ + { source: "harness" as const, pubkey: bot, name: "Old", relay: "" }, + { + source: "installed" as const, + pubkey: sibling, + name: "Older", + relay: "", + }, + ], + warnings: ["Could not read one store"], + })), + port: vi.fn( + async ( + _: string, + pubkey: string, + __: string, + ___: string, + type: string, + ) => { + const identity = { + pubkey, + relay: "wss://relay.example.test", + owner: viewer, + type, + name: "Older", + deleted: false, + }; + identities.push(identity); + return identity; + }, + ), rename: vi.fn(async (pubkey: string, name: string) => { change(pubkey, { name }); }), @@ -319,6 +351,40 @@ it("creates an agent in the community shown, with its type's defaults, and publi expect(service.find(bot)).toEqual(agent); }); +it("brings over an identity not already an agent, with its type's defaults, and publishes its profile", async () => { + const { service, native } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + const found = await service.portable(); + // `bot` is already an agent here; the warning still shows. + expect(found.agents.map((agent) => agent.pubkey)).toEqual([sibling]); + expect(found.warnings).toEqual(["Could not read one store"]); + const agent = await service.port({ + source: "installed", + pubkey: sibling, + type: "example/echo", + }); + expect(native.port).toHaveBeenCalledWith( + "installed", + sibling, + "https://relay.example.test", + viewer, + "example/echo", + ); + expect(native.publishProfile).toHaveBeenCalledWith(sibling); + expect(agent).toMatchObject({ + pubkey: sibling, + name: "Older", + config: { reply: "ok" }, + }); + expect(Object.keys(agent.attention)).toEqual([ + "interest/default", + "watch/channel", + ]); + await expect( + service.port({ source: "harness", pubkey: bot, type: "nope/none" }), + ).rejects.toThrow("That agent type is not available"); +}); + it("delivers mentions and matching watches once, and never the agent's own events", async () => { const { service, run, emit } = await setup(); await service.create({ type: "example/echo", name: "Echo" }); diff --git a/src/features/agents2/service.ts b/src/features/agents2/service.ts index 7c5fc46bc..795626734 100644 --- a/src/features/agents2/service.ts +++ b/src/features/agents2/service.ts @@ -41,6 +41,8 @@ import { type AgentIdentity, type AgentsNative, type AgentUpload, + type PortableAgents, + type PortSource, } from "./native"; import { readRecords, @@ -195,6 +197,14 @@ export type Agents2 = { * the selected one: read the agent's conversations through this. */ relay(pubkey: string): RelayData | undefined; create(input: Readonly<{ type: string; name: string }>): Promise; + /** Identities earlier agent systems saved on this machine that are not + * Agents2 agents yet. */ + portable(): Promise; + /** Takes one over as an agent of `type` in the selected community, keeping its + * key, name and picture. Its instructions and settings stay behind. */ + port( + input: Readonly<{ source: PortSource; pubkey: string; type: string }>, + ): Promise; save(pubkey: string, change: AgentChange): Promise; /** Deletes its key at once. Leaving its channels and archiving it follow in * the background whenever its community is open. */ @@ -439,6 +449,47 @@ export class Agents2Service extends Service implements Agents2 { }; async create({ type, name }: Readonly<{ type: string; name: string }>) { + if (!name.trim()) throw new Error("Name the agent"); + return this.add(type, (native, destination, owner) => + native.create(destination, owner, type, name.trim()), + ); + } + + async portable() { + const native = this.native; + if (!native) throw new Error("Agents run only in the desktop app"); + const found = await native.portable(); + const own = new Set( + this.identities + .filter((identity) => !identity.deleted) + .map((identity) => identity.pubkey), + ); + return { + ...found, + agents: found.agents.filter((agent) => !own.has(agent.pubkey)), + }; + } + + async port({ + source, + pubkey, + type, + }: Readonly<{ source: PortSource; pubkey: string; type: string }>) { + return this.add(type, (native, destination, owner) => + native.port(source, pubkey, destination, owner, type), + ); + } + + /** Saves a new identity of `type` in the selected community with the type's + * default settings. */ + private async add( + type: string, + save: ( + native: AgentsNative, + destination: string, + owner: string, + ) => Promise, + ) { const native = this.native; const selected = this.communities.snapshot().selected; const binding = selected @@ -448,13 +499,11 @@ export class Agents2Service extends Service implements Agents2 { if (!native) throw new Error("Agents run only in the desktop app"); if (!binding) throw new Error("Connect to a community first"); if (!kind) throw new Error("That agent type is not available"); - if (!name.trim()) throw new Error("Name the agent"); const settings = defaults("", kind); - const identity = await native.create( + const identity = await save( + native, relayOrigin(binding.origin), binding.viewer, - type, - name.trim(), ); const record = { ...settings, pubkey: identity.pubkey }; try { From 3a58cbc6fec5414b27ef516ffc5ee1b54ea269a2 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 16:15:55 -0700 Subject: [PATCH 03/35] fixup! [agents2-adopt] bring over agents from earlier agent systems fixup! [agents2-adopt] bring over agents from earlier agent systems Check an adopted key's community profile under its publication turn, on the first publication rather than right after bringing it over, so a sync meanwhile cannot replace it and a failed read is retried. Refuse a key that is already here or still being removed, never delete a credential an adopted key may share, read old Buzz's inline keys, and wait out a busy credential store. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen --- crates/agent-controller/src/app_agent.rs | 33 ++++++++-- .../agent-controller/src/app_agent/tests.rs | 51 +++++++++++++-- crates/agent-controller/src/port.rs | 10 +-- crates/agent-controller/src/port/tests.rs | 15 ++++- src-tauri/src/app_agents.rs | 63 ++++++++++--------- src/bundled/agents2/Agents2Page.tsx | 4 +- 6 files changed, 129 insertions(+), 47 deletions(-) diff --git a/crates/agent-controller/src/app_agent.rs b/crates/agent-controller/src/app_agent.rs index cb61d6a86..1b3ff11a6 100644 --- a/crates/agent-controller/src/app_agent.rs +++ b/crates/agent-controller/src/app_agent.rs @@ -39,6 +39,10 @@ pub struct AppAgent { /// The last profile its community accepted. #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, + /// Its key came from an earlier agent system, so its community may already + /// have a profile for it, which the first publication keeps instead. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub adopted: bool, /// Its key is gone. The row stays until its community confirms it left its /// channels and was archived, which the owner signs, not the agent. #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -349,13 +353,23 @@ impl AppAgents { picture: prepared.picture, about: None, profile: None, + adopted: prepared.adopted, deleted: false, }; let id = agent.credential_id(); // A harness agent saved for the same community keeps this key under the - // same ID. It is shared, never added again or withdrawn on failure here. + // same ID. It is shared, never added again or deleted on failure here. let shared = prepared.adopted && credentials.read(&id, &agent.pubkey)?.is_some(); self.update(|agents| { + if prepared.adopted { + if let Some(saved) = agents.iter().find(|saved| saved.pubkey == agent.pubkey) { + return Err(if saved.deleted { + "That agent is still being removed here; try again shortly".into() + } else { + "That agent is already here".into() + }); + } + } agents.retain(|saved| saved.pubkey != agent.pubkey); agents.push(agent.clone()); Ok(()) @@ -370,7 +384,12 @@ impl AppAgents { .map_err(|_| "New agent key could not be verified".into()) }); if let Err(error) = saved { - if credentials.delete(&id, &agent.pubkey).is_ok() { + // Another writer may have saved the same adopted key meanwhile; that + // copy is theirs as much as this agent's. + if prepared.adopted && matches!(credentials.read(&id, &agent.pubkey), Ok(Some(_))) { + return Ok((agent, prepared.key)); + } + if prepared.adopted || credentials.delete(&id, &agent.pubkey).is_ok() { let _ = self.update(|agents| { agents.retain(|saved| saved.pubkey != agent.pubkey); Ok(()) @@ -399,7 +418,7 @@ impl AppAgents { /// Takes the profile its community already has for an adopted key as the /// last one accepted, so the agent keeps that name and picture and nothing /// is republished until it is renamed here. A name the app could not - /// publish leaves the row as it was. + /// publish keeps the one it has here. pub fn keep_profile( &self, pubkey: &str, @@ -407,14 +426,16 @@ impl AppAgents { picture: Option, created_at: u64, ) -> Result<()> { - let name = valid_name(name)?; + let name = valid_name(name).ok(); let picture = picture.filter(|url| crate::port::public_picture(url)); self.edit(pubkey, |agent| { + if let Some(name) = name { + agent.name = name; + } agent.profile = Some(Published { - name: name.clone(), + name: agent.name.clone(), created_at, }); - agent.name = name; if picture.is_some() { agent.picture = picture; } diff --git a/crates/agent-controller/src/app_agent/tests.rs b/crates/agent-controller/src/app_agent/tests.rs index 923ab0ed7..ace920839 100644 --- a/crates/agent-controller/src/app_agent/tests.rs +++ b/crates/agent-controller/src/app_agent/tests.rs @@ -394,7 +394,6 @@ fn saved_public_profile_survives_updates_and_is_signed() { #[test] fn an_adopted_key_keeps_its_identity_picture_and_shared_saved_key() { - let dir = tempfile::tempdir().unwrap(); let credentials = Memory::default(); let key = Secret::generate().unwrap(); let pubkey = key.pubkey().to_owned(); @@ -402,7 +401,8 @@ fn an_adopted_key_keeps_its_identity_picture_and_shared_saved_key() { let id = agent_id(&pubkey, "wss://relay.example"); credentials.add(&id, &key).unwrap(); set(&credentials.1.add, true); - let agents = AppAgents::open(dir.path().join("agents2/identities.json")); + let dirs: Vec<_> = (0..3).map(|_| tempfile::tempdir().unwrap()).collect(); + let store = |n: usize| AppAgents::open(dirs[n].path().join("agents2/identities.json")); let adopt = |picture: &str| { AppAgents::adopt( "https://relay.example/", @@ -416,6 +416,7 @@ fn an_adopted_key_keeps_its_identity_picture_and_shared_saved_key() { }; let auth: Vec = serde_json::from_str(&crate::secret::test_attestation(&pubkey)).unwrap(); + let agents = store(0); let (agent, _) = agents .commit( adopt("https://relay.example/media/a.png"), @@ -424,6 +425,7 @@ fn an_adopted_key_keeps_its_identity_picture_and_shared_saved_key() { ) .unwrap(); assert_eq!(agent.pubkey, pubkey); + assert!(agent.adopted); let profile = agent .profile_event(&agent.read_key(&credentials).unwrap()) .unwrap(); @@ -431,20 +433,55 @@ fn an_adopted_key_keeps_its_identity_picture_and_shared_saved_key() { profile["content"], r#"{"bot":true,"name":"Ada","picture":"https://relay.example/media/a.png"}"# ); + // Once here, it is not taken over again, nor while it is being removed. + let again = adopt("https://a.example/b.png"); + assert!(agents.commit(again, &auth, &credentials).is_err()); + agents.edit(&pubkey, |agent| agent.deleted = true).unwrap(); + let again = adopt("https://a.example/b.png"); + assert!(agents.commit(again, &auth, &credentials).is_err()); // A shared key that fails to verify is left where it was. set(&credentials.1.read, true); - assert!(agents + assert!(store(1) .commit(adopt("https://a.example/b.png"), &auth, &credentials) .is_err()); set(&credentials.1.read, false); assert!(credentials.read(&id, &pubkey).unwrap().is_some()); + assert!(rows(&store(1)).is_empty()); // Inline pictures are not profile pictures. - let (agent, _) = agents + let (agent, _) = store(2) .commit(adopt("data:image/png;base64,AA"), &auth, &credentials) .unwrap(); assert_eq!(agent.picture, None); } +#[test] +fn an_adopted_key_that_cannot_be_saved_withdraws_only_its_row() { + let dir = tempfile::tempdir().unwrap(); + let credentials = Memory::default(); + let agents = AppAgents::open(dir.path().join("agents2/identities.json")); + let key = Secret::generate().unwrap(); + let pubkey = key.pubkey().to_owned(); + let auth: Vec = + serde_json::from_str(&crate::secret::test_attestation(&pubkey)).unwrap(); + let adopt = || { + AppAgents::adopt( + "https://relay.example/", + &owner(), + "p/t", + Secret::parse(&key.hex(), &pubkey).unwrap(), + "Ada", + None, + ) + .unwrap() + }; + set(&credentials.1.add, true); + assert!(agents.commit(adopt(), &auth, &credentials).is_err()); + assert!(rows(&agents).is_empty()); + set(&credentials.1.add, false); + let (agent, _) = agents.commit(adopt(), &auth, &credentials).unwrap(); + assert_eq!(agent.read_key(&credentials).unwrap().pubkey(), pubkey); +} + #[test] fn keeps_the_communitys_profile_for_an_adopted_key_until_renamed() { let dir = tempfile::tempdir().unwrap(); @@ -483,5 +520,9 @@ fn keeps_the_communitys_profile_for_an_adopted_key_until_renamed() { r#"{"bot":true,"name":"Ada","picture":"https://a.example/p.png"}"# ); assert!(next["created_at"].as_u64().unwrap() > 500); - assert!(agents.keep_profile(&agent.pubkey, " ", None, 1).is_err()); + // A name the app could not publish keeps the one it has. + agents.keep_profile(&agent.pubkey, " ", None, 900).unwrap(); + let kept = agents.get(&agent.pubkey).unwrap(); + assert_eq!(kept.name, "Ada"); + assert_eq!(kept.profile.unwrap().created_at, 900); } diff --git a/crates/agent-controller/src/port.rs b/crates/agent-controller/src/port.rs index 0ec074372..0205020de 100644 --- a/crates/agent-controller/src/port.rs +++ b/crates/agent-controller/src/port.rs @@ -86,20 +86,22 @@ impl OldStores { pubkey: &str, credentials: &dyn Credentials, ) -> Result<(Portable, Secret)> { - let (agent, credential) = self + let (agent, saved) = self .read(source)? .into_iter() .find(|(agent, _)| agent.pubkey == pubkey) .ok_or("That agent is no longer saved there")?; let key = match source.legacy() { + Some(_) if !saved.is_empty() => Secret::parse(&saved, pubkey)?, Some(legacy) => credentials.read_legacy(legacy, pubkey)?, None => credentials - .read(&credential, pubkey)? + .read(&saved, pubkey)? .ok_or("That agent's key is no longer saved")?, }; Ok((agent, key)) } - /// Its identities, each with the harness credential ID (empty for old Buzz). + /// Its identities, each with where its key is: the harness credential ID, or + /// the key old Buzz kept in the record itself, if it did. fn read(&self, source: PortSource) -> Result> { let (path, records) = match source.legacy() { None => (self.harness.clone(), "agents"), @@ -146,7 +148,7 @@ fn portable(source: PortSource, record: &Value) -> Option<(Portable, String)> { .filter(|id| !id.is_empty()) .map_or_else(|| agent_id(pubkey, &relay), str::to_owned) } else { - String::new() + text("private_key_nsec").to_owned() }; let picture = Some(text(if harness { "picture" } else { "avatar_url" })) .filter(|url| public_picture(url)) diff --git a/crates/agent-controller/src/port/tests.rs b/crates/agent-controller/src/port/tests.rs index 89db33356..e19423ee9 100644 --- a/crates/agent-controller/src/port/tests.rs +++ b/crates/agent-controller/src/port/tests.rs @@ -134,7 +134,7 @@ fn missing_stores_list_nothing_and_a_broken_one_hides_only_itself() { fn takes_the_key_from_the_store_it_was_listed_in() { let dir = tempfile::tempdir().unwrap(); let credentials = Memory::default(); - let (harness, old) = (key(), key()); + let (harness, old, inline) = (key(), key(), key()); let id = agent_id(harness.pubkey(), "wss://relay.example"); credentials.add(&id, &harness).unwrap(); credentials.0.lock().unwrap().insert( @@ -151,7 +151,10 @@ fn takes_the_key_from_the_store_it_was_listed_in() { legacy( dir.path(), LegacySource::Installed, - json!([{"pubkey": old.pubkey(), "name": "Bea"}]), + json!([ + {"pubkey": old.pubkey(), "name": "Bea"}, + {"pubkey": inline.pubkey(), "name": "Cy", "private_key_nsec": inline.hex().to_string()}, + ]), ); let stores = stores(dir.path()); let (agent, key) = stores @@ -165,6 +168,14 @@ fn takes_the_key_from_the_store_it_was_listed_in() { .take(PortSource::Installed, old.pubkey(), &credentials) .unwrap(); assert_eq!((agent.name.as_str(), key.pubkey()), ("Bea", old.pubkey())); + // A key old Buzz kept in the record is read from there, not the keychain. + let (_, key) = stores + .take(PortSource::Installed, inline.pubkey(), &credentials) + .unwrap(); + assert_eq!(key.pubkey(), inline.pubkey()); + assert!(!serde_json::to_string(&stores.list()) + .unwrap() + .contains(&*inline.hex())); // Each source reads only its own store. assert!(stores .take(PortSource::Development, old.pubkey(), &credentials) diff --git a/src-tauri/src/app_agents.rs b/src-tauri/src/app_agents.rs index c8ccb3eb8..5349cf6a7 100644 --- a/src-tauri/src/app_agents.rs +++ b/src-tauri/src/app_agents.rs @@ -307,7 +307,16 @@ pub(crate) async fn app_agent_port( let credentials = state.credentials.clone(); let (found, key) = blocking(move || { credentials.retry(); - old.take(source, &pubkey, credentials.as_ref()) + let mut waits = 0; + loop { + match old.take(source, &pubkey, credentials.as_ref()) { + Err(error) if error == CREDENTIALS_BUSY && waits < BUSY_WAITS => { + waits += 1; + std::thread::sleep(BUSY_WAIT); + } + taken => return taken, + } + } }) .await?; let prepared = AppAgents::adopt( @@ -323,33 +332,17 @@ pub(crate) async fn app_agent_port( .authorize_agent(owner, prepared.pubkey().to_owned()) .await?; let host = state.inner().clone(); - let saved = agents.clone(); - let (agent, key) = blocking(move || { - let mut keys = host.keys(); - let (agent, key) = saved.commit(prepared, &auth, host.credentials.as_ref())?; - let key = Arc::new(key); - keys.insert(agent.pubkey.clone(), key.clone()); - Ok((agent, key)) - }) - .await?; - // Keep the profile its community already has; without one, the next sync - // publishes the name and picture it brought. If it cannot tell, it keeps - // whatever is there rather than overwrite it, until the agent is renamed. - let (name, picture, created_at) = match current_profile(&agent, &key).await { - Ok(None) => return Ok(agent.into()), - Ok(Some(profile)) => profile, - Err(_) => (agent.name.clone(), None, 0), - }; - let pubkey = agent.pubkey.clone(); blocking(move || { - agents.keep_profile(&pubkey, &name, picture, created_at)?; - Ok(agents.get(&pubkey)?.into()) + let mut keys = host.keys(); + let (agent, key) = agents.commit(prepared, &auth, host.credentials.as_ref())?; + keys.insert(agent.pubkey.clone(), Arc::new(key)); + Ok(agent.into()) }) .await } -/// The newest profile (kind 0) its community has for the agent: its name, -/// picture and `created_at`. +/// The newest profile (kind 0) its community has for the agent: its name (empty +/// without one), picture and `created_at`. async fn current_profile( agent: &AppAgent, key: &Secret, @@ -364,8 +357,11 @@ async fn current_profile( }) else { return Ok(None); }; - let content: Value = serde_json::from_str(event["content"].as_str().unwrap_or("")) - .map_err(|_| "Invalid agent profile")?; + // One the app cannot read is replaced; one without a name keeps the + // agent's name here and the picture it has. + let Ok(content) = serde_json::from_str::(event["content"].as_str().unwrap_or("")) else { + return Ok(None); + }; let name = ["display_name", "name"] .iter() .find_map(|field| { @@ -373,7 +369,7 @@ async fn current_profile( .as_str() .filter(|name| !name.trim().is_empty()) }) - .ok_or("Agent profile has no name")?; + .unwrap_or_default(); Ok(Some(( name.to_owned(), content["picture"].as_str().map(str::to_owned), @@ -461,12 +457,22 @@ pub(crate) async fn app_agent_publish_profile( return Ok(()); } let (agent, key) = state.key(agent).await?; + let agents = state.agents.clone()?; + // An adopted key keeps the profile its community already has; only one it + // has none for publishes the name and picture it brought. Until that is + // known, nothing is published. + if agent.adopted && agent.profile.is_none() { + if let Some((name, picture, created_at)) = current_profile(&agent, &key).await? { + let pubkey = agent.pubkey.clone(); + return blocking(move || agents.keep_profile(&pubkey, &name, picture, created_at)) + .await; + } + } let signed = post(&agent, &key, agent.profile_event(&key)?).await?; let created_at = signed .get("created_at") .and_then(Value::as_u64) .ok_or("Invalid agent event")?; - let agents = state.agents.clone()?; blocking(move || { agents.published( &agent.pubkey, @@ -503,7 +509,7 @@ async fn read(agent: &AppAgent, key: &Secret, filters: &[Value]) -> Result

Keeps each agent's key, name and picture in this community. Its - instructions and settings stay behind. Stop it in old Buzz first, or - both will answer. + instructions and settings stay behind. Stop it where it runs now first, + or both will answer.

Date: Sun, 11 Oct 2026 10:13:34 -0700 Subject: [PATCH 04/35] Preserve complete publication receipts when adopting profiles Adapt adoption to the metadata layer so accepted pictures and descriptions are recorded with the name. Co-authored-by: Codex Signed-off-by: Bradley Axen --- crates/agent-controller/src/app_agent.rs | 8 +++++--- crates/agent-controller/src/app_agent/tests.rs | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/agent-controller/src/app_agent.rs b/crates/agent-controller/src/app_agent.rs index 1b3ff11a6..761b0b559 100644 --- a/crates/agent-controller/src/app_agent.rs +++ b/crates/agent-controller/src/app_agent.rs @@ -432,13 +432,15 @@ impl AppAgents { if let Some(name) = name { agent.name = name; } + if picture.is_some() { + agent.picture = picture; + } agent.profile = Some(Published { name: agent.name.clone(), + picture: agent.picture.clone(), + about: agent.about.clone(), created_at, }); - if picture.is_some() { - agent.picture = picture; - } }) } /// Deletes the key, then marks the row deleted, so a failed deletion can be diff --git a/crates/agent-controller/src/app_agent/tests.rs b/crates/agent-controller/src/app_agent/tests.rs index ace920839..eaba121b0 100644 --- a/crates/agent-controller/src/app_agent/tests.rs +++ b/crates/agent-controller/src/app_agent/tests.rs @@ -503,6 +503,8 @@ fn keeps_the_communitys_profile_for_an_adopted_key_until_renamed() { kept.profile, Some(Published { name: "Old Ada".into(), + picture: Some("https://a.example/p.png".into()), + about: None, created_at: 500 }) ); From 2f9c32fb1d329fbc959e75c84671ee30d26881fe Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 17:01:20 -0700 Subject: [PATCH 05/35] [remove-agents-v1-ui] Remove the v1 Agents page, Channel templates and agent-mention popups Delete the bundled Agents and Channel templates plugins and every v1 mention popup: wake notices, owner-review requests, the mention attach path in agent control, and snapshot previews. Adding an agent to a channel no longer starts it. Settings > Agents keeps only "Remember mentioned agents" and the saved activity archive. The shell's collapsible-sidebar, retry and icon special cases move from the v1 Agents page to Agents2. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen --- crates/plugin-manager/src/lib.rs | 9 +- crates/plugin-manager/tests/management.rs | 3 +- src/app/AgentDefaultsCard.test.tsx | 1078 ----- src/app/AgentDefaultsCard.tsx | 900 ---- src/app/AgentSettings.module.css | 58 - src/app/AgentSettings.test.tsx | 1040 +---- src/app/AgentSettings.tsx | 703 +--- src/app/App.profile.test.tsx | 31 - src/app/App.tsx | 13 - src/app/ClaudeHarnessSetup.tsx | 170 - src/app/Settings.tsx | 4 - src/app/agent-control.integration.test.ts | 190 - src/app/agent-mention.integration.test.ts | 269 -- src/app/entity-navigation.test.tsx | 103 +- src/app/navigation.ts | 2 +- src/app/pages.integration.test.mjs | 55 +- src/app/services.ts | 2 - src/app/shell/AppShell.test.tsx | 2 +- src/app/shell/AppShell.tsx | 3 +- src/app/shell/presentation.test.ts | 2 +- src/app/shell/presentation.ts | 2 +- src/bundled/agents/AgentControlPanel.test.tsx | 347 -- src/bundled/agents/AgentControlPanel.tsx | 566 --- .../agents/AgentCreateDialog.catalog.test.tsx | 252 -- src/bundled/agents/AgentCreateDialog.test.tsx | 92 - src/bundled/agents/AgentCreateDialog.tsx | 593 --- src/bundled/agents/AgentDeleteDialog.tsx | 92 - src/bundled/agents/AgentEditor.tsx | 1 + src/bundled/agents/AgentImport.test.tsx | 161 - src/bundled/agents/AgentImport.tsx | 462 --- src/bundled/agents/AgentLibrary.test.tsx | 74 - src/bundled/agents/AgentLibrary.tsx | 160 - src/bundled/agents/AgentSnapshots.test.tsx | 1591 -------- src/bundled/agents/AgentSnapshots.tsx | 751 ---- .../AgentUpdateReview.component.test.tsx | 440 -- src/bundled/agents/AgentUpdateReview.test.tsx | 256 -- src/bundled/agents/AgentUpdateReview.tsx | 395 -- src/bundled/agents/AgentsPage.test.tsx | 3606 ----------------- src/bundled/agents/AgentsPage.tsx | 534 --- .../agents/CommunityCatalog.module.css | 51 - src/bundled/agents/CommunityCatalog.test.tsx | 1007 ----- src/bundled/agents/CommunityCatalog.tsx | 882 ---- src/bundled/agents/DirectShare.test.tsx | 447 -- src/bundled/agents/DirectShare.tsx | 312 -- .../agents/InventoryIdentityCard.test.tsx | 274 -- src/bundled/agents/InventoryIdentityCard.tsx | 408 -- src/bundled/agents/InventoryView.test.tsx | 313 -- src/bundled/agents/InventoryView.tsx | 319 -- .../agents/LocalInventoryAction.test.tsx | 61 - src/bundled/agents/LocalInventoryAction.tsx | 114 - src/bundled/agents/ManagedAgentActions.tsx | 231 -- src/bundled/agents/RelayAgentRemove.tsx | 85 - src/bundled/agents/SnapshotPreview.test.tsx | 277 -- src/bundled/agents/SnapshotPreview.tsx | 197 - .../agents/TeamCatalogReshare.test.tsx | 388 -- src/bundled/agents/TeamDeployDialog.test.tsx | 72 - src/bundled/agents/TeamDeployDialog.tsx | 114 - src/bundled/agents/TeamExportDialog.test.tsx | 128 - src/bundled/agents/TeamExportDialog.tsx | 154 - src/bundled/agents/TeamImportDialog.tsx | 160 - .../agents/UnifiedInventory.native.test.tsx | 158 - src/bundled/agents/UnifiedInventory.test.tsx | 1443 ------- src/bundled/agents/UnifiedInventory.tsx | 408 -- src/bundled/agents/community-profiles.test.ts | 66 - src/bundled/agents/community-profiles.ts | 44 - src/bundled/agents/edit-route.test.ts | 18 - src/bundled/agents/edit-route.ts | 13 - src/bundled/agents/identity-tiles.test.ts | 36 - src/bundled/agents/identity-tiles.ts | 19 - src/bundled/agents/index.tsx | 40 - src/bundled/agents/inventory-archive.ts | 91 - .../agents/inventory-decisions.test.ts | 121 - src/bundled/agents/inventory-decisions.ts | 52 - src/bundled/agents/inventory-model.test.ts | 114 - src/bundled/agents/inventory-model.ts | 107 - src/bundled/agents/manifest.json | 1 - src/bundled/agents/use-community-inventory.ts | 151 - .../ChannelTemplatesDialog.catalog.test.tsx | 184 - .../ChannelTemplatesDialog.teamText.test.tsx | 490 --- .../ChannelTemplatesDialog.test.tsx | 658 --- .../ChannelTemplatesDialog.tsx | 534 --- .../channel-templates/TemplateEditor.tsx | 270 -- .../channel-templates/TemplateFields.tsx | 232 -- .../TemplateLibrary.module.css | 154 - .../TemplateLibrary.teamDelete.test.tsx | 233 -- .../channel-templates/TemplateLibrary.tsx | 714 ---- .../channel-templates/TemplateSettings.tsx | 159 - .../agent-selection.test.tsx | 2073 ---------- src/bundled/channel-templates/index.tsx | 28 - src/bundled/channel-templates/manifest.json | 5 - .../channel-templates/useTemplateCatalog.ts | 38 - .../channels/ChannelMembersDialog.test.tsx | 124 - src/bundled/channels/ChannelMembersDialog.tsx | 26 +- src/bundled/channels/ChannelsPage.tsx | 7 - src/bundled/channels/index.tsx | 3 - src/bundled/index.ts | 14 - src/bundled/sessions/SessionShare.tsx | 4 +- src/features/agents/AgentWakeNotice.test.tsx | 42 - src/features/agents/AgentWakeNotice.tsx | 19 - src/features/agents/control-native.test.ts | 14 - src/features/agents/control-native.ts | 6 - src/features/agents/control.test.ts | 278 -- src/features/agents/control.ts | 93 - src/features/agents/mention-wake.ts | 88 - src/features/agents/team-deployment.test.ts | 111 - src/features/agents/team-deployment.ts | 68 - src/features/channel-members/members.ts | 82 - .../channel-members/operations.test.ts | 65 +- src/features/channel-members/operations.ts | 45 +- .../channel-members/start-agent.test.ts | 114 - .../conversation/MessageLink.test.tsx | 42 - src/features/conversation/MessageLink.tsx | 20 - .../messages/MessageComposer.test.tsx | 50 +- src/features/messages/MessageComposer.tsx | 27 - src/features/messages/MessageRow.tsx | 11 - src/features/messages/SnapshotAttachment.tsx | 50 - .../messages/useNonmemberMentions.tsx | 4 +- src/features/relay/session.ts | 12 +- tests/browser/agent-activity.spec.mjs | 4 +- tests/browser/agent-control.spec.mjs | 1871 --------- tests/browser/agent-editor-grid.spec.mjs | 374 -- tests/browser/agent-management.spec.mjs | 130 - tests/browser/agent-models.spec.mjs | 247 -- tests/browser/agent-use-here.spec.mjs | 63 - tests/browser/agents.spec.mjs | 281 -- tests/browser/avatar-edit.spec.mjs | 141 +- tests/browser/avatar-loading.spec.mjs | 43 +- tests/browser/channel-header-menu.spec.mjs | 23 +- .../browser/channel-members-focus.fixture.tsx | 76 +- .../browser/channel-modal-dismissal.spec.mjs | 230 +- tests/browser/layout.spec.mjs | 3 - tests/browser/navigation-repairs.spec.mjs | 4 +- tests/browser/navigation-sidebar.spec.mjs | 2 +- tests/browser/navigation-sorting.spec.mjs | 2 +- tests/browser/navigation.spec.mjs | 20 +- tests/browser/sidenav-polish.spec.mjs | 14 +- tests/browser/template-dialog-layout.spec.mjs | 183 - tests/fixtures/agent-control.tsx | 60 +- tests/fixtures/agents.html | 2 - tests/fixtures/agents.tsx | 215 - tests/fixtures/avatar-loading.html | 2 + tests/fixtures/avatar-loading.tsx | 31 + tests/fixtures/dialog-gallery/Scene.tsx | 17 +- tests/fixtures/dialog-gallery/catalog.ts | 9 - tests/fixtures/mentions.tsx | 2 +- 145 files changed, 205 insertions(+), 34923 deletions(-) delete mode 100644 src/app/AgentDefaultsCard.test.tsx delete mode 100644 src/app/AgentDefaultsCard.tsx delete mode 100644 src/app/AgentSettings.module.css delete mode 100644 src/app/ClaudeHarnessSetup.tsx delete mode 100644 src/app/agent-control.integration.test.ts delete mode 100644 src/app/agent-mention.integration.test.ts delete mode 100644 src/bundled/agents/AgentControlPanel.test.tsx delete mode 100644 src/bundled/agents/AgentControlPanel.tsx delete mode 100644 src/bundled/agents/AgentCreateDialog.catalog.test.tsx delete mode 100644 src/bundled/agents/AgentCreateDialog.test.tsx delete mode 100644 src/bundled/agents/AgentCreateDialog.tsx delete mode 100644 src/bundled/agents/AgentDeleteDialog.tsx delete mode 100644 src/bundled/agents/AgentImport.test.tsx delete mode 100644 src/bundled/agents/AgentImport.tsx delete mode 100644 src/bundled/agents/AgentLibrary.test.tsx delete mode 100644 src/bundled/agents/AgentLibrary.tsx delete mode 100644 src/bundled/agents/AgentSnapshots.test.tsx delete mode 100644 src/bundled/agents/AgentSnapshots.tsx delete mode 100644 src/bundled/agents/AgentUpdateReview.component.test.tsx delete mode 100644 src/bundled/agents/AgentUpdateReview.test.tsx delete mode 100644 src/bundled/agents/AgentUpdateReview.tsx delete mode 100644 src/bundled/agents/AgentsPage.test.tsx delete mode 100644 src/bundled/agents/AgentsPage.tsx delete mode 100644 src/bundled/agents/CommunityCatalog.module.css delete mode 100644 src/bundled/agents/CommunityCatalog.test.tsx delete mode 100644 src/bundled/agents/CommunityCatalog.tsx delete mode 100644 src/bundled/agents/DirectShare.test.tsx delete mode 100644 src/bundled/agents/DirectShare.tsx delete mode 100644 src/bundled/agents/InventoryIdentityCard.test.tsx delete mode 100644 src/bundled/agents/InventoryIdentityCard.tsx delete mode 100644 src/bundled/agents/InventoryView.test.tsx delete mode 100644 src/bundled/agents/InventoryView.tsx delete mode 100644 src/bundled/agents/LocalInventoryAction.test.tsx delete mode 100644 src/bundled/agents/LocalInventoryAction.tsx delete mode 100644 src/bundled/agents/ManagedAgentActions.tsx delete mode 100644 src/bundled/agents/RelayAgentRemove.tsx delete mode 100644 src/bundled/agents/SnapshotPreview.test.tsx delete mode 100644 src/bundled/agents/SnapshotPreview.tsx delete mode 100644 src/bundled/agents/TeamCatalogReshare.test.tsx delete mode 100644 src/bundled/agents/TeamDeployDialog.test.tsx delete mode 100644 src/bundled/agents/TeamDeployDialog.tsx delete mode 100644 src/bundled/agents/TeamExportDialog.test.tsx delete mode 100644 src/bundled/agents/TeamExportDialog.tsx delete mode 100644 src/bundled/agents/TeamImportDialog.tsx delete mode 100644 src/bundled/agents/UnifiedInventory.native.test.tsx delete mode 100644 src/bundled/agents/UnifiedInventory.test.tsx delete mode 100644 src/bundled/agents/UnifiedInventory.tsx delete mode 100644 src/bundled/agents/community-profiles.test.ts delete mode 100644 src/bundled/agents/community-profiles.ts delete mode 100644 src/bundled/agents/edit-route.test.ts delete mode 100644 src/bundled/agents/edit-route.ts delete mode 100644 src/bundled/agents/identity-tiles.test.ts delete mode 100644 src/bundled/agents/identity-tiles.ts delete mode 100644 src/bundled/agents/index.tsx delete mode 100644 src/bundled/agents/inventory-archive.ts delete mode 100644 src/bundled/agents/inventory-decisions.test.ts delete mode 100644 src/bundled/agents/inventory-decisions.ts delete mode 100644 src/bundled/agents/inventory-model.test.ts delete mode 100644 src/bundled/agents/inventory-model.ts delete mode 100644 src/bundled/agents/manifest.json delete mode 100644 src/bundled/agents/use-community-inventory.ts delete mode 100644 src/bundled/channel-templates/ChannelTemplatesDialog.catalog.test.tsx delete mode 100644 src/bundled/channel-templates/ChannelTemplatesDialog.teamText.test.tsx delete mode 100644 src/bundled/channel-templates/ChannelTemplatesDialog.test.tsx delete mode 100644 src/bundled/channel-templates/ChannelTemplatesDialog.tsx delete mode 100644 src/bundled/channel-templates/TemplateEditor.tsx delete mode 100644 src/bundled/channel-templates/TemplateFields.tsx delete mode 100644 src/bundled/channel-templates/TemplateLibrary.module.css delete mode 100644 src/bundled/channel-templates/TemplateLibrary.teamDelete.test.tsx delete mode 100644 src/bundled/channel-templates/TemplateLibrary.tsx delete mode 100644 src/bundled/channel-templates/TemplateSettings.tsx delete mode 100644 src/bundled/channel-templates/agent-selection.test.tsx delete mode 100644 src/bundled/channel-templates/index.tsx delete mode 100644 src/bundled/channel-templates/manifest.json delete mode 100644 src/bundled/channel-templates/useTemplateCatalog.ts delete mode 100644 src/features/agents/AgentWakeNotice.test.tsx delete mode 100644 src/features/agents/AgentWakeNotice.tsx delete mode 100644 src/features/agents/mention-wake.ts delete mode 100644 src/features/agents/team-deployment.test.ts delete mode 100644 src/features/agents/team-deployment.ts delete mode 100644 src/features/channel-members/start-agent.test.ts delete mode 100644 src/features/messages/SnapshotAttachment.tsx delete mode 100644 tests/browser/agent-control.spec.mjs delete mode 100644 tests/browser/agent-editor-grid.spec.mjs delete mode 100644 tests/browser/agent-management.spec.mjs delete mode 100644 tests/browser/agent-models.spec.mjs delete mode 100644 tests/browser/agent-use-here.spec.mjs delete mode 100644 tests/browser/agents.spec.mjs delete mode 100644 tests/browser/template-dialog-layout.spec.mjs delete mode 100644 tests/fixtures/agents.html delete mode 100644 tests/fixtures/agents.tsx create mode 100644 tests/fixtures/avatar-loading.html create mode 100644 tests/fixtures/avatar-loading.tsx diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 7d24553a0..e22ab1631 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -172,10 +172,6 @@ pub fn bundled_manifests() -> Vec { .expect("todos manifest"), serde_json::from_str(include_str!("../../../src/bundled/diffs/manifest.json")) .expect("bundled diffs manifest"), - serde_json::from_str(include_str!( - "../../../src/bundled/channel-templates/manifest.json" - )) - .expect("channel templates manifest"), serde_json::from_str(include_str!( "../../../src/bundled/identity-naming/manifest.json" )) @@ -216,8 +212,6 @@ pub fn bundled_manifests() -> Vec { .expect("bestie manifest"), serde_json::from_str(include_str!("../../../src/bundled/projects/manifest.json")) .expect("projects manifest"), - serde_json::from_str(include_str!("../../../src/bundled/agents/manifest.json")) - .expect("agents manifest"), serde_json::from_str(include_str!("../../../src/bundled/agents2/manifest.json")) .expect("agents2 manifest"), serde_json::from_str(include_str!( @@ -264,7 +258,6 @@ fn enabled_by_default(id: &str) -> bool { | "buzz.inbox" | "buzz.reminders" | "buzz.projects" - | "buzz.agents" | "buzz.agents2" | "buzz.claude-code" | "buzz.codex" @@ -1546,7 +1539,7 @@ mod tests { #[test] fn optional_plugins_default_off_and_preserve_explicit_overrides() { - for id in ["buzz.todos", "buzz.channel-templates"] { + for id in ["buzz.todos"] { let temp = tempfile::tempdir().unwrap(); let manager = Manager::open(Some(temp.path().into()), "optional-test", false).unwrap(); let enabled = |manager: &Manager| { diff --git a/crates/plugin-manager/tests/management.rs b/crates/plugin-manager/tests/management.rs index 7ee667e92..d85b19ba3 100644 --- a/crates/plugin-manager/tests/management.rs +++ b/crates/plugin-manager/tests/management.rs @@ -534,7 +534,6 @@ fn bundled_plugins_have_independent_flags_and_all_ids_are_reserved() { "buzz.sessions", "buzz.inbox", "buzz.projects", - "buzz.agents", "buzz.emoji", "buzz.mentions", "buzz.voice-notes", @@ -671,7 +670,7 @@ fn bundled_defaults_preserve_saved_choices_and_only_channels_is_required() { .any(|plugin| plugin.manifest.id == "buzz.me")); for plugin in &catalog.plugins { let id = plugin.manifest.id.as_str(); - let default = !matches!(id, "buzz.bestie" | "buzz.todos" | "buzz.channel-templates"); + let default = !matches!(id, "buzz.bestie" | "buzz.todos"); assert_eq!(plugin.enabled, default, "{id}"); if id == "buzz.channels" { assert!(manager.change("disable", id).is_err()); diff --git a/src/app/AgentDefaultsCard.test.tsx b/src/app/AgentDefaultsCard.test.tsx deleted file mode 100644 index 7fbb06f2c..000000000 --- a/src/app/AgentDefaultsCard.test.tsx +++ /dev/null @@ -1,1078 +0,0 @@ -// @vitest-environment jsdom -import "@testing-library/jest-dom/vitest"; -import { - act, - cleanup, - render, - screen, - waitFor, - within, -} from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, expect, it } from "vitest"; -import { createAgentControl } from "../features/agents/control"; -import { controlFixture } from "../features/agents/control-testing"; -import type { ModelRequest } from "../features/agents/models"; -import { AgentDefaultsCard } from "./AgentDefaultsCard"; -import { useSyncExternalStore } from "react"; - -const disposals: (() => void)[] = []; -afterEach(() => { - cleanup(); - for (const dispose of disposals.splice(0)) dispose(); -}); - -function setup( - restarted = 0, - restartFailures = 0, - configure?: (fixture: ReturnType) => void, -) { - const fixture = controlFixture(); - fixture.data.defaultSettings = { - harness: "buzz-agent", - provider: "databricks_v2", - model: "old-model", - effort: "high", - sessionPolicy: "channel", - environmentKeys: ["SAVED_TOKEN"], - }; - configure?.(fixture); - const saveDefaults = fixture.host.saveDefaults; - if (!saveDefaults) throw Error("Missing fixture"); - fixture.host.saveDefaults = async (edit) => ({ - ...(await saveDefaults(edit)), - restarted, - restartFailures, - }); - const control = createAgentControl(fixture.host); - disposals.push(() => control.dispose()); - function Card() { - const state = useSyncExternalStore(control.subscribe, control.snapshot); - return ; - } - render(); - return { fixture, control }; -} - -it("uses harness provider choices, preserves custom IDs, and clears incompatible models", async () => { - const user = userEvent.setup(); - const { fixture, control } = setup(0, 0, (f) => { - f.data.harnessOptions?.push({ - command: "/usr/local/bin/goose", - label: "Goose", - available: true, - providers: [{ value: "anthropic", label: "Anthropic" }], - }); - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - expect(within(card).getByText(/Codex is selected per agent/i)).toBeVisible(); - await user.click( - within(card).getByRole("combobox", { name: "Default provider" }), - ); - expect( - await screen.findByRole("option", { name: "Databricks v2" }), - ).toBeVisible(); - await user.click(screen.getByRole("option", { name: "Custom ID" })); - const custom = within(card).getByRole("textbox", { - name: "Custom default provider ID", - }); - expect(custom).toHaveValue("databricks_v2"); - await user.clear(custom); - await user.type(custom, "private-provider"); - expect( - within(card).getByRole("combobox", { name: "Default model" }), - ).toHaveTextContent("Not set"); - await user.click(within(card).getByRole("button", { name: "Discard" })); - expect( - within(card).getByRole("combobox", { name: "Default provider" }), - ).toHaveTextContent("Databricks v2"); - await user.click( - within(card).getByRole("combobox", { name: "Default harness" }), - ); - await user.click(await screen.findByRole("option", { name: "Goose" })); - expect( - within(card).getByRole("combobox", { name: "Default harness" }), - ).toHaveTextContent("Goose"); - expect( - within(card).getByRole("combobox", { name: "Default model" }), - ).toHaveTextContent("Not set"); - await user.click( - within(card).getByRole("combobox", { name: "Default provider" }), - ); - expect( - await screen.findByRole("option", { name: "Anthropic" }), - ).toBeVisible(); - await user.click(screen.getByRole("option", { name: "Anthropic" })); - await user.click( - within(card).getByRole("combobox", { name: "Default model" }), - ); - await user.click(await screen.findByRole("option", { name: "Custom ID" })); - await user.type( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - "custom-goose-model", - ); - await user.keyboard("{Enter}"); - await user.click(within(card).getByRole("button", { name: "Save defaults" })); - expect(await within(card).findByText("Saved.")).toBeVisible(); - expect(fixture.data.defaultSettings).toMatchObject({ - harness: "goose", - provider: "anthropic", - model: "custom-goose-model", - }); - expect( - within(card).getByRole("combobox", { name: "Default provider" }), - ).toHaveTextContent("Anthropic"); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("custom-goose-model"); -}); - -it("looks up Pi models only on Browse, then recovers from failure using the draft", async () => { - const user = userEvent.setup(); - const requests: ModelRequest[] = []; - let attempts = 0; - const { fixture, control } = setup(0, 0, (f) => { - f.data.defaultWorkspace = "/fixture/workspace"; - f.data.harnessOptions?.push({ - command: "/usr/local/bin/buzz-pi-acp", - label: "Pi", - available: true, - providers: [], - }); - f.host.models = { - begin: async () => ++attempts, - cancel: async () => {}, - run: async (_ticket, request) => { - requests.push(request); - if (requests.length === 1) throw "Pi catalog unavailable"; - return { - host: "", - models: [{ id: "anthropic/claude-sonnet", name: "Claude Sonnet" }], - modelOverridden: false, - disconnected: false, - }; - }, - }; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - await user.click( - within(card).getByRole("combobox", { name: "Default harness" }), - ); - await user.click(await screen.findByRole("option", { name: "Pi" })); - await user.click( - within(card).getByRole("combobox", { name: "Default provider" }), - ); - expect( - await screen.findByRole("option", { - name: "OpenAI (API key may be needed)", - }), - ).toBeVisible(); - await user.click( - screen.getByRole("option", { name: "Not set (use harness default)" }), - ); - await user.click( - within(card).getByRole("combobox", { name: "Default model" }), - ); - await user.click( - await screen.findByRole("option", { - name: "Not set (use harness default)", - }), - ); - expect(requests).toHaveLength(0); - await user.click(within(card).getByRole("button", { name: "Browse models" })); - expect(await within(card).findByText("Pi catalog unavailable")).toBeVisible(); - expect( - fixture.calls.filter((call) => call.action === "saveDefaults"), - ).toHaveLength(0); - await user.click( - within(card).getByRole("combobox", { name: "Default model" }), - ); - await user.click(await screen.findByRole("option", { name: "Custom ID" })); - await user.type( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - "local/custom-model", - ); - await user.keyboard("{Enter}"); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("local/custom-model"); - await user.click(within(card).getByRole("button", { name: "Retry models" })); - expect(await within(card).findByText(/Model choices loaded/)).toBeVisible(); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("local/custom-model"); - expect(requests[1]?.edit?.harness).toMatchObject({ - command: "/usr/local/bin/buzz-pi-acp", - provider: "", - }); - await user.click( - within(card).getByRole("combobox", { name: "Default model" }), - ); - await user.click( - await screen.findByRole("option", { name: /Claude Sonnet/ }), - ); - expect( - within(card).getByRole("combobox", { name: "Default provider" }), - ).toHaveTextContent("Anthropic"); - await user.click(within(card).getByRole("button", { name: "Save defaults" })); - expect(fixture.data.defaultSettings).toMatchObject({ - harness: "pi", - provider: "anthropic", - model: "claude-sonnet", - }); - expect(await within(card).findByText("Saved.")).toBeVisible(); - expect( - within(card).getByRole("combobox", { name: "Default provider" }), - ).toHaveTextContent("Anthropic"); - expect( - within(card).getByRole("combobox", { name: "Default model" }), - ).toHaveTextContent("Claude Sonnet"); - expect( - within(card).getByRole("button", { name: "Save defaults" }), - ).toBeDisabled(); -}); - -it.each([ - { - harness: "pi" as const, - command: "/usr/local/bin/buzz-pi-acp", - label: "Pi", - provider: "google", - env: "GEMINI_API_KEY", - id: "google/gemini-2.5-pro", - }, - { - harness: "goose" as const, - command: "/usr/local/bin/goose", - label: "Goose", - provider: "google", - env: "GOOGLE_API_KEY", - id: "gemini-2.5-pro", - }, -])( - "offers a masked $harness default key for lookup and saves it write-only", - async ({ harness, command, label, provider, env, id }) => { - const user = userEvent.setup(); - const requests: ModelRequest[] = []; - const { fixture, control } = setup(0, 0, (f) => { - f.data.defaultSettings = { - harness, - provider, - model: "gemini-2.5-pro", - effort: "", - sessionPolicy: "channel", - environmentKeys: [env], - }; - f.data.harnessOptions?.push({ - command, - label, - available: true, - providers: [{ value: provider, label: "Google Gemini" }], - }); - f.host.models = { - begin: async () => 1, - cancel: async () => {}, - run: async (_ticket, request) => { - requests.push(request); - return { - host: "", - models: [{ id, name: "Gemini 2.5 Pro" }], - modelOverridden: false, - disconnected: false, - }; - }, - }; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - const key = within(card).getByLabelText("Google Gemini API key"); - expect(key).toHaveAttribute("type", "password"); - expect(key).toHaveValue(""); - expect(key).toHaveAttribute("placeholder", "Saved key unchanged"); - await user.type(key, "replacement-key"); - expect(key).toHaveAttribute("type", "password"); - await user.click( - within(card).getByRole("button", { name: "Show API key" }), - ); - expect(key).toHaveAttribute("type", "text"); - await user.click( - within(card).getByRole("button", { name: "Browse models" }), - ); - expect(await within(card).findByText(/Model choices loaded/)).toBeVisible(); - expect(requests[0]?.edit?.environment).toEqual({ - [env]: "replacement-key", - }); - await user.click( - within(card).getByRole("button", { name: "Save defaults" }), - ); - expect(await within(card).findByText("Saved.")).toBeVisible(); - expect( - fixture.calls.find((call) => call.action === "saveDefaults"), - ).toMatchObject({ - payload: { edit: { environment: { [env]: "replacement-key" } } }, - }); - expect(within(card).getByLabelText("Google Gemini API key")).toHaveValue( - "", - ); - expect( - within(card).getByLabelText("Google Gemini API key"), - ).toHaveAttribute("placeholder", "Saved key unchanged"); - expect(card).not.toHaveTextContent("replacement-key"); - await user.type( - within(card).getByLabelText("Google Gemini API key"), - "next-key", - ); - expect( - within(card).getByLabelText("Google Gemini API key"), - ).toHaveAttribute("type", "password"); - }, -); - -it("searches the full catalog by ID while bounding choices and distinguishing duplicate names", async () => { - const user = userEvent.setup(); - const requests: ModelRequest[] = []; - const { fixture, control } = setup(0, 0, (f) => { - f.data.defaultSettings = { - harness: "goose", - provider: "openai", - model: "", - effort: "", - sessionPolicy: "channel", - environmentKeys: [], - }; - f.data.harnessOptions?.push({ - command: "/usr/local/bin/goose", - label: "Goose", - available: true, - providers: [{ value: "openai", label: "OpenAI" }], - }); - f.host.models = { - begin: async () => 1, - cancel: async () => {}, - run: async (_ticket, request) => { - requests.push(request); - return { - host: "", - models: Array.from({ length: 12 }, (_, index) => ({ - id: `model-${index}`, - name: "Same name", - })), - modelOverridden: false, - disconnected: false, - }; - }, - }; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - await user.click(within(card).getByRole("button", { name: "Browse models" })); - expect(await within(card).findByText(/Model choices loaded/)).toBeVisible(); - await user.click( - within(card).getByRole("combobox", { name: "Default model" }), - ); - expect( - await screen.findByRole("option", { name: "Same name · model-0" }), - ).toBeVisible(); - expect(screen.getAllByRole("option")).toHaveLength(12); // Ten models, default, custom. - await user.keyboard("{Escape}"); - await user.type( - within(card).getByRole("textbox", { name: "Search models" }), - "model-11", - ); - await user.click( - within(card).getByRole("combobox", { name: "Default model" }), - ); - await user.click( - await screen.findByRole("option", { name: "Same name · model-11" }), - ); - await user.clear( - within(card).getByRole("textbox", { name: "Search models" }), - ); - await user.type( - within(card).getByRole("textbox", { name: "Search models" }), - "no-match", - ); - expect( - within(card).getByRole("combobox", { name: "Default model" }), - ).toHaveTextContent("model-11"); - expect(requests).toHaveLength(1); - await user.type(within(card).getByLabelText("OpenAI API key"), "draft-key"); - expect(within(card).getByLabelText("OpenAI API key")).toHaveFocus(); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("model-11"); - await user.click(within(card).getByRole("button", { name: "Save defaults" })); - expect(await within(card).findByText("Saved.")).toBeVisible(); - expect(fixture.data.defaultSettings?.model).toBe("model-11"); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("model-11"); -}); - -it.each([ - { - environment: { DATABRICKS_HOST: "https://draft.example.test" }, - host: "", - filter: "build-filter", - }, - { - environment: { DATABRICKS_MODEL_FILTER: "" }, - host: "https://build.example.test", - filter: "", - }, - { - environment: { - DATABRICKS_HOST: "https://draft.example.test", - DATABRICKS_MODEL_FILTER: "draft-filter", - }, - host: "", - filter: "", - }, -])( - "browses with draft Databricks environment $environment without saving", - async ({ environment, host, filter }) => { - const user = userEvent.setup(); - const requests: ModelRequest[] = []; - const { fixture, control } = setup(0, 0, (f) => { - f.data.databricksDefaults = { - host: "https://build.example.test", - filter: "build-filter", - }; - f.host.models = { - begin: async () => 1, - cancel: async () => {}, - run: async (_ticket, request) => { - requests.push(request); - return { - host: "", - models: [{ id: "draft-model", name: "Draft model" }], - modelOverridden: false, - disconnected: false, - }; - }, - }; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - await user.click( - within(card).getByRole("button", { name: "Add environment variable" }), - ); - for (const [key, value] of Object.entries(environment)) { - await user.type(within(card).getByLabelText("Name"), key); - if (value) await user.type(within(card).getByLabelText("Value"), value); - await user.click( - within(card).getByRole("button", { name: "Add variable" }), - ); - } - await user.click( - within(card).getByRole("button", { name: "Browse models" }), - ); - expect(await within(card).findByText(/Model choices loaded/)).toBeVisible(); - expect(requests[0]).toMatchObject({ - host, - filter, - inheritWorkspace: true, - edit: { environment }, - }); - expect( - fixture.calls.filter((call) => call.action === "saveDefaults"), - ).toHaveLength(0); - }, -); - -it.each([ - { - provider: "databricks_v2", - savedKeys: [], - draft: undefined, - buildVisible: true, - }, - { provider: "openai", savedKeys: [], draft: undefined, buildVisible: false }, - { - provider: "", - savedKeys: ["BUZZ_AGENT_PROVIDER"], - draft: undefined, - buildVisible: false, - }, - { - provider: "databricks_v2", - savedKeys: ["DATABRICKS_MODEL"], - draft: undefined, - buildVisible: false, - }, - { - provider: "databricks_v2", - savedKeys: ["BUZZ_AGENT_MODEL"], - draft: undefined, - buildVisible: false, - }, - { - provider: "databricks_v2", - savedKeys: [], - draft: { key: "BUZZ_AGENT_PROVIDER", value: "openai" }, - buildVisible: false, - }, - { - provider: "openai", - savedKeys: [], - draft: { key: "BUZZ_AGENT_PROVIDER", value: "databricks-v2" }, - buildVisible: true, - }, - { - provider: "databricks_v2", - savedKeys: [], - draft: { key: "DATABRICKS_MODEL", value: "" }, - buildVisible: false, - }, -])( - "names the build model only when its fallback applies: $provider $savedKeys $draft", - async ({ provider, savedKeys, draft, buildVisible }) => { - const user = userEvent.setup(); - const { control } = setup(0, 0, (f) => { - if (!f.data.defaultSettings) throw Error("Missing defaults fixture"); - Object.assign(f.data.defaultSettings, { - provider, - model: "", - environmentKeys: savedKeys, - }); - f.data.agentDefaults = { - provider: "databricks_v2", - model: "build-model", - ownerOnly: true, - }; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - if (draft) { - await user.click( - within(card).getByRole("button", { name: "Add environment variable" }), - ); - await user.type(within(card).getByLabelText("Name"), draft.key); - if (draft.value) - await user.type(within(card).getByLabelText("Value"), draft.value); - await user.click( - within(card).getByRole("button", { name: "Add variable" }), - ); - } - expect( - within(card).getByRole("combobox", { name: "Default model" }), - ).toHaveTextContent( - buildVisible - ? "Use build default (build-model)" - : "Not set (use harness default)", - ); - }, -); - -it("hands keyboard focus between a listed choice and its custom input", async () => { - const user = userEvent.setup(); - const { control } = setup(); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - within(card).getByRole("combobox", { name: "Default effort" }).focus(); - await user.keyboard("{Enter}"); - expect( - await screen.findByRole("option", { name: "Custom ID" }), - ).toBeVisible(); - await waitFor(() => - expect(screen.getByRole("option", { name: "High" })).toHaveFocus(), - ); - await user.keyboard("{End}"); - await waitFor(() => - expect(screen.getByRole("option", { name: "Custom ID" })).toHaveFocus(), - ); - await user.keyboard("{Enter}"); - expect( - within(card).getByRole("textbox", { name: "Custom default effort ID" }), - ).toHaveFocus(); - await user.keyboard("{Shift>}{Tab}{/Shift}{Enter}"); - expect(await screen.findByRole("option", { name: "None" })).toBeVisible(); - await waitFor(() => - expect(screen.getByRole("option", { name: "Custom ID" })).toHaveFocus(), - ); - await user.keyboard("{Home}{ArrowDown}"); - await waitFor(() => - expect(screen.getByRole("option", { name: "None" })).toHaveFocus(), - ); - await user.keyboard("{Enter}"); - expect( - within(card).queryByRole("textbox", { name: "Custom default effort ID" }), - ).not.toBeInTheDocument(); - expect( - within(card).getByRole("combobox", { name: "Default effort" }), - ).toHaveFocus(); - expect( - within(card).getByRole("combobox", { name: "Default effort" }), - ).toHaveTextContent("None"); -}); - -it.each([false, true])( - "discards a typed Pi key when clearing Model while retaining saved keys (%s)", - async (savedKey) => { - const user = userEvent.setup(); - const { fixture, control } = setup(0, 0, (f) => { - if (!f.data.defaultSettings) throw Error("Missing defaults fixture"); - Object.assign(f.data.defaultSettings, { - harness: "pi", - provider: "openai", - model: "gpt-test", - environmentKeys: savedKey ? ["OPENAI_API_KEY"] : [], - }); - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - await user.type( - within(card).getByLabelText("OpenAI API key"), - "unsaved-key", - ); - await user.click( - within(card).getByRole("combobox", { name: "Default model" }), - ); - await user.click( - await screen.findByRole("option", { - name: "Not set (use harness default)", - }), - ); - await user.click( - within(card).getByRole("button", { name: "Save defaults" }), - ); - expect(await within(card).findByText("Saved.")).toBeVisible(); - expect( - fixture.calls.find((call) => call.action === "saveDefaults")?.payload, - ).toEqual({ - edit: expect.objectContaining({ - provider: "", - model: "", - environment: {}, - }), - }); - expect(fixture.data.defaultSettings?.environmentKeys).toEqual( - savedKey ? ["OPENAI_API_KEY"] : [], - ); - }, -); - -it.each([ - { harness: "buzz-agent" as const, override: "BUZZ_AGENT_MODEL" }, - { harness: "goose" as const, override: "GOOSE_MODEL" }, -])( - "warns about $override before browsing and respects its draft removal", - async ({ harness, override }) => { - const user = userEvent.setup(); - const { control } = setup(0, 0, (f) => { - if (!f.data.defaultSettings) throw Error("Missing defaults"); - f.data.defaultSettings.harness = harness; - f.data.defaultSettings.environmentKeys = [override]; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - expect( - within(card).getByText( - `${override} overrides this model selection. Replace or remove it under Environment variables.`, - ), - ).toBeVisible(); - await user.click( - within(card).getByRole("button", { name: `Remove ${override}` }), - ); - expect( - within(card).queryByText(/overrides this model selection/), - ).toBeNull(); - }, -); - -it("uses the native selector keys for defaults warnings instead of inferring them from the harness", async () => { - const { control } = setup(0, 0, (f) => { - const policy = f.data.harnessOptions?.[0]?.configurationPolicy; - if (!policy || !f.data.defaultSettings) - throw Error("Missing policy fixture"); - // A native contract change must take effect without a second UI mapping. - policy.selectorEnvironment = { - model: "NATIVE_MODEL", - provider: "NATIVE_PROVIDER", - }; - f.data.defaultSettings.environmentKeys = [ - "NATIVE_MODEL", - "NATIVE_PROVIDER", - ]; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - expect( - within(card).getByText(/NATIVE_MODEL overrides this model selection/), - ).toBeVisible(); - expect( - within(card).getByText(/NATIVE_PROVIDER overrides this provider selection/), - ).toBeVisible(); -}); - -it("waits for a hidden Goose provider override to be removed before offering its key", async () => { - const user = userEvent.setup(); - const { control } = setup(0, 0, (f) => { - f.data.defaultSettings = { - harness: "goose", - provider: "openai", - model: "", - effort: "", - sessionPolicy: "channel", - environmentKeys: ["GOOSE_PROVIDER"], - }; - f.data.harnessOptions?.push({ - command: "/usr/local/bin/goose", - label: "Goose", - available: true, - providers: [], - }); - f.host.models = { - begin: async () => 1, - cancel: async () => {}, - run: async () => { - throw "Unexpected lookup"; - }, - }; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - expect(within(card).queryByLabelText("OpenAI API key")).toBeNull(); - expect( - within(card).getByText(/saved GOOSE_PROVIDER override has a hidden value/), - ).toBeVisible(); - await user.click( - within(card).getByRole("button", { name: "Remove GOOSE_PROVIDER" }), - ); - expect(within(card).getByLabelText("OpenAI API key")).toHaveAttribute( - "type", - "password", - ); - expect( - within(card).getByRole("button", { name: "Browse models" }), - ).toBeDisabled(); - expect( - within(card).getByText( - "Save environment removals before browsing models so lookup uses the updated settings.", - ), - ).toBeVisible(); -}); - -it("drops an unsaved Pi key when its provider changes", async () => { - const user = userEvent.setup(); - const { fixture, control } = setup(0, 0, (f) => { - f.data.defaultSettings = { - harness: "pi", - provider: "openai", - model: "gpt-5", - effort: "", - sessionPolicy: "channel", - environmentKeys: [], - }; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - await user.type(within(card).getByLabelText("OpenAI API key"), "draft-key"); - await user.click( - within(card).getByRole("combobox", { name: "Default provider" }), - ); - await user.click( - await screen.findByRole("option", { - name: /Anthropic \(API key may be needed\)/, - }), - ); - expect(within(card).queryByLabelText("OpenAI API key")).toBeNull(); - expect(within(card).getByLabelText("Anthropic API key")).toHaveValue(""); - expect(within(card).queryByText("OPENAI_API_KEY")).toBeNull(); - await user.type( - within(card).getByLabelText("Anthropic API key"), - "another-key", - ); - await user.click( - within(card).getByRole("combobox", { name: "Default harness" }), - ); - await user.click(await screen.findByRole("option", { name: "Goose" })); - expect(within(card).queryByText("ANTHROPIC_API_KEY")).toBeNull(); - await user.click(within(card).getByRole("button", { name: "Save defaults" })); - expect( - fixture.calls.find((call) => call.action === "saveDefaults"), - ).toMatchObject({ - payload: { - edit: { harness: "goose", provider: "anthropic", environment: {} }, - }, - }); -}); - -it("saves a selected effort level", async () => { - const user = userEvent.setup(); - const { fixture, control } = setup(); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - await user.click( - within(card).getByRole("combobox", { name: "Default effort" }), - ); - expect(await screen.findByRole("option", { name: "Medium" })).toBeVisible(); - await user.click(screen.getByRole("option", { name: "Medium" })); - await user.click(within(card).getByRole("button", { name: "Save defaults" })); - expect(await within(card).findByText("Saved.")).toBeVisible(); - expect(fixture.data.defaultSettings?.effort).toBe("medium"); -}); - -it("keeps a custom effort editable and clears it on harness changes", async () => { - const user = userEvent.setup(); - const { control } = setup(0, 0, (fixture) => { - if (!fixture.data.defaultSettings) throw Error("Missing defaults"); - fixture.data.defaultSettings.effort = "special-level"; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - expect( - within(card).getByRole("textbox", { name: "Custom default effort ID" }), - ).toHaveValue("special-level"); - await user.click( - within(card).getByRole("combobox", { name: "Default harness" }), - ); - await user.click(await screen.findByRole("option", { name: "Goose" })); - await user.click( - within(card).getByRole("combobox", { name: "Default effort" }), - ); - expect(await screen.findByRole("option", { name: "Off" })).toBeVisible(); - expect(screen.getByRole("option", { name: "Max" })).toBeVisible(); - await user.click(screen.getByRole("option", { name: "Max" })); - expect( - within(card).getByRole("combobox", { name: "Default effort" }), - ).toHaveTextContent("Max"); - await user.click( - within(card).getByRole("combobox", { name: "Default harness" }), - ); - await user.click(await screen.findByRole("option", { name: "Pi" })); - expect( - within(card).getByRole("combobox", { name: "Default harness" }), - ).toHaveTextContent("Pi"); - expect( - within(card).getByRole("combobox", { name: "Default effort" }), - ).toHaveTextContent("Not set"); - await user.click( - within(card).getByRole("combobox", { name: "Default effort" }), - ); - expect(await screen.findByRole("option", { name: "Off" })).toBeVisible(); -}); - -it("cancels an in-flight lookup without losing the editable defaults draft", async () => { - const user = userEvent.setup(); - let started!: () => void; - const start = new Promise((resolve) => { - started = resolve; - }); - let finished!: () => void; - const finish = new Promise((resolve) => { - finished = resolve; - }); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - const { fixture, control } = setup(0, 0, (f) => { - f.host.models = { - begin: async () => 1, - cancel: async () => {}, - run: async () => { - started(); - await gate; - finished(); - return { - host: "", - models: [{ id: "late-model", name: "Late model" }], - modelOverridden: false, - disconnected: false, - }; - }, - }; - }); - try { - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - await user.click( - within(card).getByRole("button", { name: "Browse models" }), - ); - await start; - expect( - await within(card).findByRole("button", { name: "Cancel model lookup" }), - ).toBeVisible(); - await user.click( - within(card).getByRole("button", { name: "Cancel model lookup" }), - ); - expect( - await within(card).findByText("Cancelled. Retry when ready."), - ).toBeVisible(); - await user.clear( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ); - await user.type( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - "manual-model", - ); - await user.keyboard("{Enter}"); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("manual-model"); - expect(fixture.data.defaultSettings?.model).toBe("old-model"); - await act(async () => { - release(); - await finish; - }); - expect( - within(card).getByRole("button", { name: "Retry models" }), - ).toBeEnabled(); - await user.click( - within(card).getByRole("combobox", { name: "Default model" }), - ); - expect( - await screen.findByRole("option", { name: "Custom ID" }), - ).toBeVisible(); - expect(screen.queryByRole("option", { name: /Late model/ })).toBeNull(); - await user.keyboard("{Escape}"); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("manual-model"); - } finally { - release(); - } -}); - -it("discard clears unfinished environment inputs as well as the saved draft", async () => { - const user = userEvent.setup(); - const { control } = setup(); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - const add = within(card).getByRole("button", { - name: "Add environment variable", - }); - expect(add).toHaveAttribute("aria-expanded", "false"); - expect(within(card).getByLabelText("Name")).not.toBeVisible(); - await user.click(add); - await user.type(within(card).getByLabelText("Name"), "UNSAVED_TOKEN"); - await user.type(within(card).getByLabelText("Value"), "unfinished-secret"); - await user.click(add); - await user.click(add); - expect(within(card).getByLabelText("Name")).toHaveValue("UNSAVED_TOKEN"); - expect(within(card).getByLabelText("Value")).toHaveValue("unfinished-secret"); - expect(within(card).getByRole("button", { name: "Discard" })).toBeVisible(); - await user.type( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - "-draft", - ); - await user.keyboard("{Enter}"); - await user.click(within(card).getByRole("button", { name: "Discard" })); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("old-model"); - expect(within(card).getByLabelText("Name")).toHaveValue(""); - expect(within(card).getByLabelText("Value")).toHaveValue(""); -}); - -it("changing the default harness keeps provider, clears model and effort, and saves write-only env", async () => { - const user = userEvent.setup(); - const { fixture, control } = setup(2, 1); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("old-model"); - // Saved environment values are never shown; only the key and its state. - expect(within(card).getByText("SAVED_TOKEN")).toBeVisible(); - expect(card).not.toHaveTextContent("secret"); - await user.click( - within(card).getByRole("combobox", { name: "Default harness" }), - ); - await user.click(await screen.findByRole("option", { name: "Goose" })); - await user.click( - within(card).getByRole("combobox", { name: "Conversation context" }), - ); - await user.click(await screen.findByRole("option", { name: "Each thread" })); - expect( - within(card).getByRole("combobox", { name: "Default provider" }), - ).toHaveTextContent("Custom ID"); - expect( - within(card).getByRole("textbox", { name: "Custom default provider ID" }), - ).toHaveValue("databricks_v2"); - expect( - within(card).getByRole("combobox", { name: "Default model" }), - ).toHaveTextContent("Not set"); - expect(within(card).getByLabelText("Default effort")).toHaveValue(""); - await user.click( - within(card).getByRole("button", { name: "Add environment variable" }), - ); - await user.type(within(card).getByLabelText("Name"), "NEW_KEY"); - await user.type(within(card).getByLabelText("Value"), "secret-value"); - await user.click(within(card).getByRole("button", { name: "Add variable" })); - expect(within(card).getByLabelText("Name")).toHaveFocus(); - await user.click( - within(card).getByRole("button", { name: "Remove SAVED_TOKEN" }), - ); - await user.click(within(card).getByRole("button", { name: "Save defaults" })); - expect( - await within(card).findByText( - "Saved. Restarted 2 agents. 1 agent couldn’t restart with the new settings; check Agents.", - ), - ).toBeVisible(); - expect(fixture.calls.find((c) => c.action === "saveDefaults")).toEqual({ - action: "saveDefaults", - payload: { - edit: { - harness: "goose", - provider: "databricks_v2", - model: "", - effort: "", - sessionPolicy: "thread", - environment: { NEW_KEY: "secret-value", SAVED_TOKEN: null }, - }, - }, - }); - expect(within(card).getByText("NEW_KEY")).toBeVisible(); - expect(within(card).queryByText("SAVED_TOKEN")).toBeNull(); - expect(card).not.toHaveTextContent("secret-value"); -}); - -it("keeps the uncertain-write explanation when Stop overtakes a committed save", async () => { - const user = userEvent.setup(); - const { fixture, control } = setup(); - await control.refresh(); - const commit = fixture.host.saveDefaults; - if (!commit) throw Error("Missing fixture"); - let committed!: () => void; - const written = new Promise((resolve) => { - committed = resolve; - }); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - // Native commits the defaults, then waits on a restart credential prompt. - fixture.host.saveDefaults = async (edit) => { - const saved = await commit(edit); - committed(); - await gate; - return saved; - }; - const card = await screen.findByRole("region", { name: "Agent defaults" }); - await user.clear( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ); - await user.type( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - "committed", - ); - await user.keyboard("{Enter}"); - try { - await user.click( - within(card).getByRole("button", { name: "Save defaults" }), - ); - await written; - await control.action(fixture.agent.id, "stop"); - } finally { - release(); - } - const alert = await within(card).findByRole("alert"); - expect(alert).toHaveTextContent("Could not confirm the operation"); - expect(alert).toHaveTextContent("Check current status and saved settings"); - expect(alert).not.toHaveTextContent("weren’t saved"); - expect(fixture.data.defaultSettings?.model).toBe("committed"); -}); diff --git a/src/app/AgentDefaultsCard.tsx b/src/app/AgentDefaultsCard.tsx deleted file mode 100644 index 09d6668aa..000000000 --- a/src/app/AgentDefaultsCard.tsx +++ /dev/null @@ -1,900 +0,0 @@ -import { SettingsGroup } from "../shared/design-system/ui/SettingsGroup"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { - savedMessage, - type AgentControl, - type AgentControlState, - type AgentDefaultSettings, - type AgentDefaultsEdit, - type AgentEdit, - type HarnessConfigurationPolicy, -} from "../features/agents/control"; -import type { ModelCatalog } from "../features/agents/models"; -import { - effectiveGooseProvider, - gooseApiKey, - PI_API_KEYS, - harnessKind, -} from "../bundled/agents/agent-edit"; -import { ProviderApiKeyField } from "../bundled/agents/ProviderApiKeyField"; -import { InlineHeader } from "../shared/design-system/ui/Header"; -import { Button } from "../shared/design-system/ui/Button"; -import { Accordion } from "../shared/design-system/ui/Accordion"; -import { Field } from "../shared/design-system/ui/Field"; -import { Input } from "../shared/design-system/ui/Input"; -import { Select } from "../shared/design-system/ui/Select"; -import styles from "./AgentSettings.module.css"; - -const harnesses = [ - { value: "buzz-agent", label: "Buzz Agent" }, - { value: "goose", label: "Goose" }, - { value: "pi", label: "Pi" }, -] as const; - -// Legacy suggestions only, never model capability evidence. Native policy marks -// effort discovery unknown until the integration reports model-specific metadata. -const effortChoices = { - "buzz-agent": ["none", "minimal", "low", "medium", "high", "xhigh", "max"], - goose: ["off", "low", "medium", "high", "max"], - pi: ["off", "minimal", "low", "medium", "high", "xhigh"], -} as const; - -const VISIBLE_MODEL_LIMIT = 10; - -function defaultLabel(harness: AgentDefaultsEdit["harness"], value: string) { - return value && harness === "buzz-agent" - ? `Use build default (${value})` - : "Not set (use harness default)"; -} - -type Choice = { value: string; label: string }; - -function defaultHarness( - state: AgentControlState, - kind: AgentDefaultsEdit["harness"], -) { - return state.data?.harnessOptions?.find( - (option) => harnessKind(option.command) === kind, - ); -} - -function environmentSet( - current: AgentDefaultsEdit, - savedKeys: string[], - key: string, -) { - return ( - typeof current.environment[key] === "string" || - (current.environment[key] === undefined && savedKeys.includes(key)) - ); -} - -/** A saved ID stays editable even when it is absent from today's suggestions. */ -function DefaultsChoice({ - label, - value, - customValue = value, - choices, - disabled, - onSelect, - onCustom, - resetKey, -}: { - label: string; - value: string; - customValue?: string; - choices: Choice[]; - disabled: boolean; - onSelect(value: string): void; - onCustom(value: string): void; - resetKey: string; -}) { - const [custom, setCustom] = useState(false); - const container = useRef(null); - const input = useRef(null); - const focusAfterSelection = useRef<"input" | "trigger" | null>(null); - // Only a user-selected mode change transfers focus; catalog retirement must - // leave focus in the credential or environment field being edited. - useLayoutEffect(() => { - if (focusAfterSelection.current === "input") input.current?.focus(); - else if (focusAfterSelection.current === "trigger") - container.current - ?.querySelector('[role="combobox"]') - ?.focus(); - focusAfterSelection.current = null; - }); - // biome-ignore lint/correctness/useExhaustiveDependencies: a new editing session resets the local input mode. - useEffect(() => setCustom(false), [resetKey]); - const index = choices.findIndex((choice) => choice.value === value); - const showInput = custom || index < 0; - return ( -
- { - setCustom(true); - onCustom(event.target.value); - }} - /> - - )} -
- ); -} - -function ProviderChoice({ - current, - state, - models, - disabled, - onChange, - editSession, -}: { - current: AgentDefaultsEdit; - state: AgentControlState; - models: ModelCatalog["models"]; - disabled: boolean; - onChange(provider: string): void; - editSession: number; -}) { - const harness = defaultHarness(state, current.harness); - const discoveredProviders = harness?.configurationPolicy - ? harness.configurationPolicy.provider === "discovered" - : current.harness === "pi"; - const discovered = discoveredProviders - ? [...new Set(models.map((model) => model.id.split("/")[0] ?? ""))].filter( - Boolean, - ) - : []; - const providers = discoveredProviders - ? [ - ...discovered.map((value) => ({ - value, - label: `${PI_API_KEYS[value]?.label ?? value} (available in Pi)`, - })), - ...Object.entries(PI_API_KEYS) - .filter(([value]) => !discovered.includes(value)) - .map(([value, details]) => ({ - value, - label: `${details.label} (API key may be needed)`, - })), - ] - : (harness?.providers ?? []); - const builtInProvider = state.data?.agentDefaults?.provider ?? ""; - const builtInLabel = - providers.find((provider) => provider.value === builtInProvider)?.label ?? - builtInProvider; - const overrideKey = harness?.configurationPolicy - ? harness.configurationPolicy.selectorEnvironment?.provider - : current.harness === "goose" - ? "GOOSE_PROVIDER" - : current.harness === "buzz-agent" - ? "BUZZ_AGENT_PROVIDER" - : null; - const providerOverridden = - !!overrideKey && - environmentSet( - current, - state.data?.defaultSettings?.environmentKeys ?? [], - overrideKey, - ); - return ( -
- - {providerOverridden && ( -

- {overrideKey} overrides this provider selection. Replace or remove it - under Environment variables. -

- )} -
- ); -} - -function defaultsApiKey( - current: AgentDefaultsEdit, - savedKeys: string[], - policy?: HarnessConfigurationPolicy, -) { - if (policy?.authentication === "external") return undefined; - if (current.harness === "pi") return PI_API_KEYS[current.provider]; - if (current.harness !== "goose") return undefined; - const provider = effectiveGooseProvider( - current.provider, - current.environment, - savedKeys, - ); - return provider ? gooseApiKey(provider) : undefined; -} - -function ModelChoice({ - control, - state, - current, - disabled, - onChange, - onModels, - editSession, -}: { - control: AgentControl; - state: AgentControlState; - current: AgentDefaultsEdit; - disabled: boolean; - onChange(patch: Partial): void; - onModels(models: ModelCatalog["models"]): void; - editSession: number; -}) { - const harness = defaultHarness(state, current.harness); - const policy = harness?.configurationPolicy; - const pi = policy - ? policy.provider === "discovered" - : current.harness === "pi"; - const [catalog, setCatalog] = useState<{ - key: string; - models: ModelCatalog["models"]; - } | null>(null); - const [status, setStatus] = useState(""); - const [busy, setBusy] = useState(false); - const [attempted, setAttempted] = useState(false); - const [query, setQuery] = useState(""); - const pending = useRef(null); - const key = JSON.stringify([ - current.harness, - pi ? null : current.provider, - harness?.command, - harness?.defaultArgs, - current.environment, - state.data?.defaultWorkspace, - state.data?.databricksDefaults, - state.data?.defaultSettings?.environmentKeys, - ]); - const currentKey = useRef(key); - currentKey.current = key; - // biome-ignore lint/correctness/useExhaustiveDependencies: search text belongs to this provider and editing session. - useEffect(() => setQuery(""), [current.provider, editSession]); - // biome-ignore lint/correctness/useExhaustiveDependencies: key is the lookup context; a changed context retires native work. - useEffect(() => { - pending.current?.abort(); - pending.current = null; - setBusy(false); - setAttempted(false); - setStatus(""); - setQuery(""); - onModels([]); - return () => { - pending.current?.abort(); - }; - }, [key]); - const fresh = catalog?.key === key ? catalog.models : []; - const entries = fresh.filter( - (model) => - !pi || !current.provider || model.id.startsWith(`${current.provider}/`), - ); - const selectedId = - pi && current.provider && current.model - ? `${current.provider}/${current.model}` - : current.model; - const matching = entries - .filter((model) => - `${model.name} ${model.id}`.toLowerCase().includes(query.toLowerCase()), - ) - .slice(0, VISIBLE_MODEL_LIMIT); - // Filtering must not turn the current catalog choice into a custom ID. - const selected = entries.find((model) => model.id === selectedId); - if (selected && !matching.some((model) => model.id === selected.id)) - matching.unshift(selected); - const modelKey = policy - ? policy.selectorEnvironment?.model - : { - "buzz-agent": "BUZZ_AGENT_MODEL", - goose: "GOOSE_MODEL", - pi: "", - }[current.harness]; - const savedKeys = state.data?.defaultSettings?.environmentKeys ?? []; - const modelOverridden = - !!modelKey && environmentSet(current, savedKeys, modelKey); - const providerOverride = current.environment.BUZZ_AGENT_PROVIDER; - const effectiveProvider = - typeof providerOverride === "string" - ? providerOverride - : environmentSet(current, savedKeys, "BUZZ_AGENT_PROVIDER") - ? null - : current.provider || state.data?.agentDefaults?.provider; - const buildModelApplies = - current.harness === "buzz-agent" && - ["databricks_v2", "databricks-v2", "databricks"].includes( - effectiveProvider ?? "", - ) && - !modelOverridden && - !environmentSet(current, savedKeys, "DATABRICKS_MODEL"); - const removingEnvironment = Object.values(current.environment).includes(null); - const choices: Choice[] = [ - { - value: "", - label: defaultLabel( - current.harness, - buildModelApplies ? (state.data?.agentDefaults?.model ?? "") : "", - ), - }, - ...matching.map((model) => ({ - value: model.id, - label: model.name === model.id ? model.id : `${model.name} · ${model.id}`, - })), - ]; - const choose = (value: string) => { - if (pi && !value) onChange({ provider: "", model: "" }); - else if (pi && fresh.some((model) => model.id === value)) { - const slash = value.indexOf("/"); - onChange({ - provider: value.slice(0, slash), - model: value.slice(slash + 1), - }); - } else onChange({ model: value }); - }; - const browse = async () => { - if ( - !control.models || - !harness || - harness.available === false || - removingEnvironment || - pending.current - ) - return; - const run = new AbortController(); - pending.current = run; - setBusy(true); - setAttempted(true); - setStatus(`Loading ${harness.label} models…`); - const environmentHost = environmentSet( - current, - savedKeys, - "DATABRICKS_HOST", - ); - const environmentFilter = environmentSet( - current, - savedKeys, - "DATABRICKS_MODEL_FILTER", - ); - const edit: AgentEdit = { - name: "", - systemPrompt: "", - sessionPolicy: current.sessionPolicy, - workspace: state.data?.defaultWorkspace ?? "", - harness: { - command: harness.command, - args: harness.defaultArgs ?? [], - provider: - current.harness === "buzz-agent" && !current.provider - ? state.data?.agentDefaults?.provider || "databricks_v2" - : current.provider, - model: current.model, - }, - environment: current.environment, - }; - try { - const result = await control.models.request( - { - edit, - host: - current.harness === "buzz-agent" && !environmentHost - ? (state.data?.databricksDefaults?.host ?? "") - : "", - filter: - current.harness === "buzz-agent" && !environmentFilter - ? (state.data?.databricksDefaults?.filter ?? "") - : "", - action: "connect", - integration: harness.id, - selectedModel: current.model || undefined, - ...(environmentHost || environmentFilter - ? { inheritWorkspace: true } - : {}), - }, - run.signal, - ); - if (run.signal.aborted || currentKey.current !== key) return; - setCatalog({ key, models: result.models }); - onModels(result.models); - setStatus( - result.modelOverridden - ? "A saved environment model override takes precedence over this selection." - : result.models.length - ? "Model choices loaded. Availability does not confirm inference access." - : `No ${harness.label} models found. Enter a custom model ID or retry.`, - ); - } catch (problem) { - if (!run.signal.aborted && currentKey.current === key) - setStatus((problem as Error).message); - } finally { - if (pending.current === run) { - pending.current = null; - setBusy(false); - } - } - }; - return ( -
- {entries.length > VISIBLE_MODEL_LIMIT && ( - - setQuery(event.target.value)} - /> - - )} - onChange({ model })} - /> - {modelOverridden && ( -

- {modelKey} overrides this model selection. Replace or remove it under - Environment variables. -

- )} -
- {busy ? ( - - ) : ( - - )} - {status && ( -

- {status} -

- )} -
- {harness?.available === false && ( -

- Install {harness.label} to browse its models. Custom IDs remain - editable. -

- )} - {!control.models && ( -

- Model browsing requires an updated desktop app. Custom IDs remain - editable. -

- )} - {current.harness === "goose" && !current.provider && ( -

- Choose a Goose provider to browse its models. -

- )} - {(policy ? policy.model === "withProvider" : pi) && - current.provider && - !current.model && ( -

- Choose a model for this Pi provider, or clear Provider to use Pi - defaults. -

- )} - {removingEnvironment && ( -

- Save environment removals before browsing models so lookup uses the - updated settings. -

- )} -
- ); -} - -function draftFrom(saved: AgentDefaultSettings): AgentDefaultsEdit { - const { environmentKeys: _keys, ...fields } = saved; - return { ...fields, environment: {} }; -} - -/** Device-wide defaults. Environment values are write-only: only keys return. */ -export function AgentDefaultsCard({ - control, - state, -}: { - control: AgentControl; - state: AgentControlState; -}) { - const saved = state.data?.defaultSettings; - const [draft, setDraft] = useState(null); - const [newKey, setNewKey] = useState(""); - const newKeyInput = useRef(null); - const [newValue, setNewValue] = useState(""); - const [notice, setNotice] = useState(""); - const [error, setError] = useState(""); - const [models, setModels] = useState([]); - const [editSession, setEditSession] = useState(0); - if (!saved || !control.saveDefaults) return null; - const current = draft ?? draftFrom(saved); - const disabled = state.busy || state.status !== "ready"; - const policy = defaultHarness(state, current.harness)?.configurationPolicy; - const apiKey = defaultsApiKey(current, saved.environmentKeys, policy); - const gooseProvider = - current.harness === "goose" - ? effectiveGooseProvider( - current.provider, - current.environment, - saved.environmentKeys, - ) - : undefined; - const change = (patch: Partial) => { - setNotice(""); - const next = { ...current, ...patch }; - const previousKey = apiKey?.env; - if ( - previousKey && - (next.harness !== current.harness || - defaultsApiKey( - next, - saved.environmentKeys, - defaultHarness(state, next.harness)?.configurationPolicy, - )?.env !== previousKey) && - typeof next.environment[previousKey] === "string" - ) { - next.environment = { ...next.environment }; - delete next.environment[previousKey]; - } - setDraft(next); - }; - const keys = [ - ...new Set([...saved.environmentKeys, ...Object.keys(current.environment)]), - ].sort(); - const save = () => { - setError(""); - void control.saveDefaults?.(current).then( - (snapshot) => { - setDraft(null); - setNewKey(""); - setNewValue(""); - setEditSession((session) => session + 1); - setNotice(savedMessage(snapshot.restarted, snapshot.restartFailures)); - }, - // The controller's sanitized reason: a save may already have committed - // (e.g. Stop overtook its restart), so never claim nothing was saved. - (problem: Error) => setError(problem.message), - ); - }; - return ( -
- - - - change({ - sessionPolicy: - sessionPolicy as AgentDefaultsEdit["sessionPolicy"], - }) - } - description="Share one conversation across the channel, or keep threads separate. Direct messages stay shared." - /> -
- Environment variables -

- Added to every agent; an agent’s own key wins. Saved values stay on - this device and are never shown again. -

- {keys.length > 0 && ( -
    - {keys.map((key) => { - const removed = current.environment[key] === null; - return ( -
  • - {key} - - - {removed - ? "Removed on save" - : typeof current.environment[key] === "string" - ? "Set on save" - : "Set"} - - - -
  • - ); - })} -
- )} - - - - setNewKey(event.target.value.trim()) - } - /> - - - setNewValue(event.target.value)} - /> - -
- -
-
- ), - }, - ]} - /> - -
- {(draft || newKey || newValue) && ( - - )} - -
- {notice && ( -

- {notice} -

- )} - {error && ( -

- {error} -

- )} - - - ); -} diff --git a/src/app/AgentSettings.module.css b/src/app/AgentSettings.module.css deleted file mode 100644 index 607cea12d..000000000 --- a/src/app/AgentSettings.module.css +++ /dev/null @@ -1,58 +0,0 @@ -.rows { - margin: var(--space-3) 0; - padding: 0; - list-style: none; -} - -.rows > li + li { - border-top: 1px solid var(--border-standard); -} - -.piSetup { - margin-top: var(--space-3); - margin-left: var(--space-2); - padding-left: var(--space-4); - border-left: 1px solid var(--border-standard); -} - -.command { - overflow-wrap: anywhere; - user-select: all; -} - -.catalog { - display: grid; - grid-template-columns: 12rem minmax(0, 1fr); - gap: var(--space-6); - min-height: 100%; -} - -.catalogSidebar { - border-right: 1px solid var(--border-standard); - padding-right: var(--space-4); -} - -.catalogDetails { - min-width: 0; - padding: var(--space-6); - border-radius: var(--radius-control); - background: var(--surface-inset); -} - -@media (max-width: 640px) { - .catalog { - grid-template-columns: minmax(0, 1fr); - gap: var(--space-3); - } - - .catalogDetails { - padding: var(--space-4); - } - - .catalogSidebar { - border-right: 0; - border-bottom: 1px solid var(--border-standard); - padding-right: 0; - padding-bottom: var(--space-3); - } -} diff --git a/src/app/AgentSettings.test.tsx b/src/app/AgentSettings.test.tsx index f4147ff22..72d5a52b5 100644 --- a/src/app/AgentSettings.test.tsx +++ b/src/app/AgentSettings.test.tsx @@ -1,35 +1,17 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import { ToastProvider } from "../shared/design-system/ui/Toast"; -import { - act, - cleanup, - render, - screen, - waitFor, - within, -} from "@testing-library/react"; +import { act, cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; -import { - createAgentControl, - type AgentControlHost, - type HarnessInstallReport, -} from "../features/agents/control"; -import { controlFixture } from "../features/agents/control-testing"; import { AgentSettings } from "./AgentSettings"; -import { AgentHarnessEditor } from "../bundled/agents/AgentHarnessEditor"; -import { agentDraft } from "../bundled/agents/agent-edit"; import { rememberAgentsPreference, setRememberAgentsPreference, } from "../features/messages/mention-preferences"; -const browserControl = createAgentControl(null); -const disposals: (() => void)[] = []; afterEach(() => { cleanup(); - for (const dispose of disposals.splice(0)) dispose(); vi.restoreAllMocks(); setRememberAgentsPreference(true); localStorage.clear(); @@ -37,7 +19,7 @@ afterEach(() => { it("defaults on, persists opt-out, and follows another window's preference", async () => { const user = userEvent.setup(); - render(, { + render(, { wrapper: ToastProvider, }); const toggle = () => @@ -46,7 +28,7 @@ it("defaults on, persists opt-out, and follows another window's preference", asy await user.click(toggle()); expect(rememberAgentsPreference()).toBe(false); cleanup(); - render(, { + render(, { wrapper: ToastProvider, }); expect(toggle()).not.toBeChecked(); @@ -61,7 +43,7 @@ it("defaults on, persists opt-out, and follows another window's preference", asy it("keeps an unsaved opt-out effective and offers retry without changing the choice", async () => { const user = userEvent.setup(); - render(, { + render(, { wrapper: ToastProvider, }); const write = vi @@ -86,1017 +68,3 @@ it("does not prefill when storage cannot be read", () => { }); expect(rememberAgentsPreference()).toBe(false); }); - -function setupHarnesses( - piStatus: "ready" | "cli-needed" | "adapter-needed", - pi: { - installSupported?: boolean; - updateSupported?: boolean; - installPi?: NonNullable; - claude?: NonNullable< - ReturnType["data"]["claudeSetup"] - >; - installClaude?: NonNullable; - checkClaudeAuth?: () => Promise; - } = {}, -) { - const fixture = controlFixture(); - if (pi.installPi) fixture.host.installPi = pi.installPi; - if (pi.installClaude) fixture.host.installClaude = pi.installClaude; - if (pi.checkClaudeAuth) - Object.assign(fixture.host, { checkClaudeAuth: pi.checkClaudeAuth }); - if (pi.claude) fixture.data.claudeSetup = pi.claude; - fixture.data.harnessOptions = [ - { - command: "buzz-agent", - label: "Buzz Agent", - available: true, - status: "ready", - providers: [], - }, - { - command: "goose", - label: "Goose", - available: true, - status: "ready", - providers: [], - }, - { - command: "buzz-pi-acp", - label: "Pi", - available: piStatus === "ready", - status: piStatus, - ...(pi.installSupported !== undefined - ? { installSupported: pi.installSupported } - : {}), - ...(pi.updateSupported !== undefined - ? { updateSupported: pi.updateSupported } - : {}), - providers: [], - }, - { - command: "hermes-acp", - label: "Hermes Agent", - available: false, - status: "cli-needed", - installSupported: false, - providers: [], - }, - ]; - const control = createAgentControl(fixture.host); - disposals.push(() => control.dispose()); - render(, { wrapper: ToastProvider }); - return { fixture, control }; -} - -it.each(["cli-needed", "ready"] as const)( - "shows Harnesses and keeps manual Pi commands collapsed until requested (%s)", - async (piStatus) => { - const user = userEvent.setup(); - setupHarnesses(piStatus); - const list = await screen.findByRole("list", { name: "Harnesses" }); - const rows = within(list) - .getAllByRole("listitem") - .filter((row) => row.parentElement === list); - expect(rows).toHaveLength(3); - const pi = rows[2]; - if (!pi) throw new Error("Missing Pi row"); - expect(rows[0]).toHaveTextContent("Buzz AgentReady"); - expect(rows[1]).toHaveTextContent("GooseReady"); - expect(rows[2]).toHaveTextContent( - `Pi${piStatus === "ready" ? "Ready" : piStatus === "cli-needed" ? "CLI needed" : "Adapter needed"}`, - ); - const copyPi = screen.queryByRole("button", { name: "Copy Pi command" }); - if (piStatus === "ready") { - expect(copyPi).not.toBeInTheDocument(); - expect(screen.queryByText(/npm install -g/)).not.toBeInTheDocument(); - } else { - expect(screen.getByLabelText("Copy Pi command")).not.toBeVisible(); - const manual = within(pi).getByText("Manual setup"); - expect(manual.closest("details")).not.toHaveAttribute("open"); - await user.click(manual); - const steps = within( - screen.getByRole("list", { name: "Manual setup steps" }), - ).getAllByRole("listitem"); - expect(steps).toHaveLength(4); - expect(steps[0]).toHaveTextContent("Install Node.js"); - expect(steps[1]).toHaveTextContent("Install Pi"); - expect(steps[2]).toHaveTextContent("Install the ACP adapter"); - expect(steps[3]).toHaveTextContent("Check the installation"); - expect( - screen.getByText( - "npm install -g '@earendil-works/pi-coding-agent@>=0.99.0'", - ), - ).toBeVisible(); - expect( - screen.getByText( - /git\+https:\/\/github.com\/salman1993\/buzz-pi-acp.git#72015de/, - ), - ).toBeVisible(); - const write = vi - .spyOn(navigator.clipboard, "writeText") - .mockResolvedValue(); - await user.click(screen.getByRole("button", { name: "Copy Pi command" })); - expect(write).toHaveBeenCalledWith( - "npm install -g '@earendil-works/pi-coding-agent@>=0.99.0'", - ); - await user.click( - screen.getByRole("button", { name: "Copy Adapter command" }), - ); - expect(write).toHaveBeenCalledWith( - "npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#72015de'", - ); - expect(await screen.findByRole("status", { name: "" })).toHaveTextContent( - "Adapter command copied.", - ); - } - await user.hover(screen.getByRole("button", { name: "About ACP" })); - expect(await screen.findByRole("tooltip")).toHaveTextContent( - "Hermes Agent uses its own ACP launcher and sign-in.", - ); - }, -); - -it("discovers Tier 2 Hermes through Add harness and follows installation changes", async () => { - const user = userEvent.setup(); - const { fixture } = setupHarnesses("ready"); - const list = await screen.findByRole("list", { name: "Harnesses" }); - expect(within(list).queryByText("Hermes Agent")).not.toBeInTheDocument(); - const add = screen.getByRole("button", { name: "Add harness" }); - await user.click(add); - const dialog = await screen.findByRole("dialog", { name: "Add harness" }); - expect(within(dialog).getByText("CLI needed")).toBeVisible(); - expect( - within(dialog).queryByRole("button", { name: /Install|Update/ }), - ).toBeNull(); - expect( - within(dialog).getByRole("link", { name: "Hermes Agent setup guide" }), - ).toHaveAttribute( - "href", - "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp/", - ); - await user.click(within(dialog).getByText("Terminal setup")); - expect(within(dialog).getByText("hermes model")).toBeVisible(); - expect(within(dialog).getByText("hermes acp --check")).toBeVisible(); - const hermes = fixture.data.harnessOptions?.find( - (option) => option.label === "Hermes Agent", - ); - if (!hermes) throw new Error("Missing Hermes option"); - Object.assign(hermes, { - command: "/local/hermes-acp", - available: true, - status: "ready", - }); - await user.click(within(dialog).getByRole("button", { name: "Check again" })); - await waitFor(() => expect(within(dialog).getByText("Ready")).toBeVisible()); - expect(within(dialog).getByText("/local/hermes-acp")).toBeVisible(); - await user.keyboard("{Escape}"); - await waitFor(() => - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), - ); - await waitFor(() => expect(add).toHaveFocus()); - expect( - within(list).getByText("Hermes Agent").closest("li"), - ).toHaveTextContent("Ready"); - expect( - within(list).queryByText(/Uses the default model and credentials/), - ).toBeNull(); - expect(within(list).queryByText("Manual Hermes Agent setup")).toBeNull(); - Object.assign(hermes, { - command: "hermes-acp", - available: false, - status: "cli-needed", - }); - await user.click(screen.getByRole("button", { name: "Check again" })); - await waitFor(() => - expect(within(list).queryByText("Hermes Agent")).not.toBeInTheDocument(), - ); - await user.click(add); - expect( - within(await screen.findByRole("dialog")).getByText("CLI needed"), - ).toBeVisible(); -}); - -it("keeps Tier 1 harnesses usable when an older desktop has no Hermes option", async () => { - const user = userEvent.setup(); - const { fixture, control } = setupHarnesses("ready"); - fixture.data.harnessOptions = (fixture.data.harnessOptions ?? []).filter( - (option) => option.label !== "Hermes Agent", - ); - await act(() => control.refresh()); - const list = await screen.findByRole("list", { name: "Harnesses" }); - expect(within(list).getByText("Goose")).toBeVisible(); - await user.click(screen.getByRole("button", { name: "Add harness" })); - expect( - await screen.findByRole("dialog", { name: "Add harness" }), - ).toHaveTextContent("Update the desktop app to check Hermes Agent."); -}); - -it("recognizes harness commands independently of native display labels", async () => { - const { fixture, control } = setupHarnesses("ready"); - for (const option of fixture.data.harnessOptions ?? []) { - option.label = `Renamed ${option.label}`; - } - await act(() => control.refresh()); - const list = await screen.findByRole("list", { name: "Harnesses" }); - expect(within(list).getByText("Renamed Goose")).toBeVisible(); - expect( - within(list).queryByText("Renamed Hermes Agent"), - ).not.toBeInTheDocument(); - const user = userEvent.setup(); - await user.click(screen.getByRole("button", { name: "Add harness" })); - const dialog = await screen.findByRole("dialog", { name: "Add harness" }); - expect( - within(dialog).getByRole("heading", { name: "Renamed Hermes Agent" }), - ).toBeVisible(); - expect( - within(dialog).getByRole("link", { - name: "Renamed Hermes Agent setup guide", - }), - ).toHaveAttribute( - "href", - "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp/", - ); -}); - -type CodexStatus = "ready" | "cli-needed" | "adapter-needed"; -function codexHarnessOptions(status: CodexStatus, installSupported = true) { - return [ - ...(["buzz-agent", "goose", "buzz-pi-acp"] as const).map((command) => ({ - command, - label: command, - available: true, - status: "ready" as const, - providers: [], - })), - { - id: "codex" as const, - command: "/tools/codex-acp", - label: "Codex", - available: status === "ready", - status, - installSupported, - providers: [], - }, - ]; -} -function codexHarnesses(status: CodexStatus, installSupported = true) { - const fixture = controlFixture(); - fixture.data.harnessOptions = codexHarnessOptions(status, installSupported); - return fixture; -} -const codexRow = () => { - const row = screen.getByText("Codex").closest("li"); - if (!row) throw new Error("Missing Codex row"); - return within(row); -}; - -it("reads Codex sign-in only once its tools are found, and rechecks on Check again", async () => { - const user = userEvent.setup(); - const fixture = codexHarnesses("cli-needed"); - const checkCodexAuth = vi - .fn>() - .mockResolvedValue(false); - fixture.host.checkCodexAuth = checkCodexAuth; - const control = createAgentControl(fixture.host); - disposals.push(() => control.dispose()); - render(, { wrapper: ToastProvider }); - - await screen.findByText("Codex"); - const codexIcon = screen - .getByText("Codex") - .closest("li") - ?.querySelector("svg"); - expect(codexIcon).not.toHaveClass("tabler-icon"); - expect(codexIcon).toHaveAttribute("viewBox", "176 176 364 364"); - expect(await codexRow().findByText("CLI needed")).toBeVisible(); - expect(codexRow().getByText(/Install the Codex CLI/)).toBeVisible(); - expect(codexRow().queryByRole("button", { name: "Install" })).toBeNull(); - expect(checkCodexAuth).not.toHaveBeenCalled(); - - fixture.data.harnessOptions = codexHarnessOptions("ready"); - await user.click(screen.getByRole("button", { name: "Check again" })); - expect(await codexRow().findByText("Sign-in needed")).toBeVisible(); - expect(codexRow().getByText(/Run codex login/)).toBeVisible(); - - let finish!: (signedIn: boolean) => void; - checkCodexAuth.mockReturnValueOnce( - new Promise((resolve) => { - finish = resolve; - }), - ); - await user.click(screen.getByRole("button", { name: "Check again" })); - try { - expect(await codexRow().findByText("Checking sign-in…")).toBeVisible(); - expect(codexRow().queryByText(/Run codex login/)).toBeNull(); - } finally { - await act(async () => finish(true)); - } - expect(await codexRow().findByText("Ready")).toBeVisible(); - // The status is the whole success state; no message or version suffix. - expect(screen.getByText("Codex").closest("li")).toHaveTextContent( - /^CodexReady$/, - ); -}); - -it("offers the Codex ACP adapter Install only when the adapter is missing, then re-detects", async () => { - const user = userEvent.setup(); - const unsupported = codexHarnesses("adapter-needed", false); - unsupported.host.installCodex = vi.fn(); - const blocked = createAgentControl(unsupported.host); - disposals.push(() => blocked.dispose()); - const first = render(, { - wrapper: ToastProvider, - }); - await screen.findByText("Codex"); - expect(await codexRow().findByText("Adapter needed")).toBeVisible(); - expect(codexRow().queryByRole("button", { name: "Install" })).toBeNull(); - first.unmount(); - - const fixture = codexHarnesses("adapter-needed"); - fixture.host.checkCodexAuth = vi.fn(async () => true); - const failed: HarnessInstallReport = { - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/codex-install.log", - output: "npm ERR! network", - error: "Installing the Codex ACP adapter failed", - }; - let complete!: (report: HarnessInstallReport) => void; - const install = vi - .fn>() - .mockResolvedValueOnce(failed) - .mockImplementationOnce( - () => - new Promise((resolve) => { - complete = resolve; - }), - ); - fixture.host.installCodex = install; - const control = createAgentControl(fixture.host); - disposals.push(() => control.dispose()); - render(, { wrapper: ToastProvider }); - - await screen.findByText("Codex"); - expect(await codexRow().findByText("Adapter needed")).toBeVisible(); - expect( - codexRow().getByText(/Buzz installs Node.js and the Codex ACP adapter/), - ).toBeVisible(); - await user.click(codexRow().getByText("Manual Codex ACP adapter setup")); - expect( - codexRow().getByText("npm install -g @agentclientprotocol/codex-acp@2.1.1"), - ).toBeVisible(); - - await user.click(codexRow().getByRole("button", { name: "Install" })); - const alert = await codexRow().findByRole("alert"); - expect(alert).toHaveTextContent("Installing the Codex ACP adapter failed"); - await user.click(within(alert).getByText("Codex ACP adapter install log")); - expect(within(alert).getByText("npm ERR! network")).toBeVisible(); - - await user.click(codexRow().getByRole("button", { name: "Install" })); - expect( - await screen.findByText("Installing Node.js and the Codex ACP adapter…"), - ).toBeVisible(); - // Loading stays focusable for keyboard users but cannot start another install. - const installing = codexRow().getByRole("button", { name: "Install" }); - expect(installing).toHaveAttribute("aria-disabled", "true"); - await user.click(installing); - expect(install).toHaveBeenCalledTimes(2); - fixture.data.harnessOptions = codexHarnessOptions("ready"); - await act(async () => - complete({ ...failed, ready: true, error: null, output: "" }), - ); - expect(await codexRow().findByText("Ready")).toBeVisible(); - expect(codexRow().queryByRole("button", { name: "Install" })).toBeNull(); - expect(install).toHaveBeenCalledTimes(2); -}); - -it.each([false, true] as const)( - "keeps Codex keyboard retry usable and hands off success only while Install owns focus (moved: %s)", - async (moveFocus) => { - const user = userEvent.setup(); - const report: HarnessInstallReport = { - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/codex-install.log", - output: "npm ERR! network", - error: "Installation failed", - }; - let finish!: (report: HarnessInstallReport) => void; - const pending = new Promise((resolve) => { - finish = resolve; - }); - const install = vi - .fn>() - .mockReturnValue(pending); - const fixture = codexHarnesses("adapter-needed"); - fixture.host.installCodex = install; - fixture.host.checkCodexAuth = async () => !moveFocus; - const control = createAgentControl(fixture.host); - disposals.push(() => control.dispose()); - render(, { wrapper: ToastProvider }); - - await screen.findByText("Codex"); - const button = await codexRow().findByRole("button", { name: "Install" }); - for (let i = 0; i < 20 && document.activeElement !== button; i++) { - await user.tab(); - } - expect(button).toHaveFocus(); - await user.keyboard("{Enter}"); - try { - expect( - await screen.findByText( - "Installing Node.js and the Codex ACP adapter…", - ), - ).toBeVisible(); - expect(button).toHaveFocus(); - } finally { - await act(async () => { - finish(report); - await pending; - }); - } - expect(await codexRow().findByRole("alert")).toHaveTextContent( - "Installation failed", - ); - expect(button).toHaveFocus(); - const retry = new Promise((resolve) => { - finish = resolve; - }); - install.mockReturnValue(retry); - await user.keyboard("{Enter}"); - await waitFor(() => expect(install).toHaveBeenCalledTimes(2)); - let next: Element | null = null; - try { - expect(button).toHaveFocus(); - if (moveFocus) { - next = screen.getByRole("button", { name: "Add harness" }); - for (let i = 0; i < 20 && document.activeElement !== next; i++) { - await user.tab(); - } - expect(next).toHaveFocus(); - } - fixture.data.harnessOptions = codexHarnessOptions("ready"); - } finally { - await act(async () => { - finish({ ...report, ready: true, error: null }); - await retry; - }); - } - const status = await codexRow().findByText( - moveFocus ? "Sign-in needed" : "Ready", - ); - expect(codexRow().queryByRole("button", { name: "Install" })).toBeNull(); - if (moveFocus) expect(next).toHaveFocus(); - else expect(status).toHaveFocus(); - }, -); - -it("offers manual copying when clipboard access fails", async () => { - const user = userEvent.setup(); - setupHarnesses("cli-needed"); - await user.click(await screen.findByText("Manual setup")); - expect( - await screen.findByRole("button", { name: "Copy Pi command" }), - ).toBeVisible(); - vi.spyOn(navigator.clipboard, "writeText").mockRejectedValue( - new Error("denied"), - ); - await user.click(screen.getByRole("button", { name: "Copy Pi command" })); - expect(await screen.findByRole("status")).toHaveTextContent( - "Select it to copy manually.", - ); - expect( - screen.getByText( - "npm install -g '@earendil-works/pi-coding-agent@>=0.99.0'", - ), - ).toBeVisible(); -}); - -it("Check again re-reads the native snapshot without restarting the app", async () => { - const user = userEvent.setup(); - const { fixture } = setupHarnesses("adapter-needed"); - expect(await screen.findByText("Adapter needed")).toBeVisible(); - await user.hover(screen.getByRole("button", { name: "Check again" })); - expect(await screen.findByRole("tooltip")).toHaveTextContent("Check again"); - const before = fixture.calls.filter( - (call) => call.action === "snapshot", - ).length; - const pi = fixture.data.harnessOptions?.[2]; - const goose = fixture.data.harnessOptions?.[1]; - if (!pi || !goose) throw new Error("Missing Harness fixture"); - pi.status = "ready"; - pi.available = true; - goose.status = "ready"; - goose.available = true; - await user.click(screen.getByRole("button", { name: "Check again" })); - await waitFor(() => - expect( - within(screen.getByRole("list", { name: "Harnesses" })).getAllByRole( - "listitem", - )[2], - ).toHaveTextContent("PiReady"), - ); - expect(screen.queryByRole("button", { name: "Copy Pi command" })).toBeNull(); - expect( - within(screen.getByRole("list", { name: "Harnesses" })).getAllByRole( - "listitem", - )[1], - ).toHaveTextContent("GooseReady"); - expect( - fixture.calls.filter((call) => call.action === "snapshot"), - ).toHaveLength(before + 1); -}); - -it("keeps the last statuses and offers Check again after a failed read", async () => { - const user = userEvent.setup(); - const installPi = vi.fn(); - const { fixture } = setupHarnesses("cli-needed", { - installSupported: true, - installPi, - }); - const list = await screen.findByRole("list", { name: "Harnesses" }); - const piRow = within(list).getByText("Pi", { exact: true }).closest("li"); - if (!piRow) throw new Error("Missing Pi row"); - expect(within(piRow).getByText("CLI needed")).toBeVisible(); - const original = fixture.host.snapshot.bind(fixture.host); - fixture.host.snapshot = vi - .fn() - .mockRejectedValueOnce("unavailable") - .mockImplementation(original); - await user.click(screen.getByRole("button", { name: "Check again" })); - expect(await screen.findByRole("alert")).toHaveTextContent("last check"); - expect(screen.getByRole("button", { name: "Install" })).toBeDisabled(); - expect(installPi).not.toHaveBeenCalled(); - expect( - within(screen.getByRole("list", { name: "Harnesses" })).getAllByRole( - "listitem", - )[0], - ).toHaveTextContent("Buzz AgentReady"); - await user.click(screen.getByRole("button", { name: "Check again" })); - await waitFor(() => expect(screen.queryByRole("alert")).toBeNull()); - expect(screen.getByRole("button", { name: "Install" })).toBeEnabled(); -}); - -it.each([ - ["cli-needed", true, true], - ["ready", true, false], - ["cli-needed", false, false], -] as const)( - "shows Pi Install only when needed and supported (%s, %s)", - async (status, supported, visible) => { - setupHarnesses(status, { installSupported: supported, installPi: vi.fn() }); - const pi = within( - await screen.findByRole("list", { name: "Harnesses" }), - ).getAllByRole("listitem")[2]; - if (!pi) throw new Error("Missing Pi row"); - expect(within(pi).queryByRole("button", { name: "Install" }) !== null).toBe( - visible, - ); - if (status !== "ready") { - expect(screen.getByLabelText("Copy Pi command")).not.toBeVisible(); - if (supported) - expect( - within(pi).getByText( - /Buzz installs Node.js, Pi, and its ACP adapter for you/, - ), - ).toBeVisible(); - else - expect( - within(pi).getByText(/Use Manual setup on this device/), - ).toBeVisible(); - } - }, -); - -it("offers no Pi update or command for a current or user-global Pi install", async () => { - setupHarnesses("ready", { - installSupported: true, - updateSupported: false, - installPi: vi.fn(), - }); - const pi = within( - await screen.findByRole("list", { name: "Harnesses" }), - ).getAllByRole("listitem")[2]; - if (!pi) throw new Error("Missing Pi row"); - expect(within(pi).getByText("Ready")).toBeVisible(); - expect(screen.queryByRole("button", { name: "Update Pi" })).toBeNull(); - expect( - screen.queryByRole("button", { name: "Copy Adapter command" }), - ).toBeNull(); - expect(screen.queryByText(/buzz-pi-acp.git#/)).toBeNull(); -}); - -it("updates an outdated app-owned Pi and tells the user to restart running agents", async () => { - const user = userEvent.setup(); - const installPi = vi.fn().mockResolvedValue({ - ready: true, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/pi-install.log", - output: "done", - error: null, - }); - setupHarnesses("ready", { - installSupported: true, - updateSupported: true, - installPi, - }); - const pi = within( - await screen.findByRole("list", { name: "Harnesses" }), - ).getAllByRole("listitem")[2]; - if (!pi) throw new Error("Missing Pi row"); - await user.click(within(pi).getByRole("button", { name: "Update Pi" })); - expect(installPi).toHaveBeenCalledTimes(1); - expect( - await screen.findByText(/Restart running Pi agents to use them/), - ).toBeVisible(); -}); - -it("keeps Pi install progress and report across Settings remounts without taking the agent-write lane", async () => { - const user = userEvent.setup(); - let complete!: (report: HarnessInstallReport) => void; - const installing = new Promise((resolve) => { - complete = resolve; - }); - const installPi = vi.fn(() => installing); - const { control, fixture } = setupHarnesses("adapter-needed", { - installSupported: true, - installPi, - }); - const pi = within( - await screen.findByRole("list", { name: "Harnesses" }), - ).getAllByRole("listitem")[2]; - if (!pi) throw new Error("Missing Pi row"); - await user.click(within(pi).getByRole("button", { name: "Install" })); - expect( - await screen.findByText("Installing Pi and its ACP adapter…"), - ).toBeVisible(); - expect(control.snapshot().busy).toBe(false); - expect(installPi).toHaveBeenCalledTimes(1); - cleanup(); - render(, { wrapper: ToastProvider }); - expect( - await screen.findByText("Installing Pi and its ACP adapter…"), - ).toBeVisible(); - const option = fixture.data.harnessOptions?.[2]; - if (!option) throw new Error("Missing Pi fixture"); - option.status = "ready"; - option.available = true; - complete({ - ready: true, - restarted: 1, - restartFailures: 0, - logPath: "/fixture/pi-install.log", - output: "done", - error: null, - }); - expect( - await screen.findByText( - /Pi and its adapter are up to date.*Restarted 1 waiting agents\./, - ), - ).toBeVisible(); - cleanup(); - render(, { wrapper: ToastProvider }); - expect( - await screen.findByText( - /Pi and its adapter are up to date.*Restarted 1 waiting agents\./, - ), - ).toBeVisible(); - expect(screen.queryByRole("button", { name: "Copy Pi command" })).toBeNull(); -}); - -it("keeps bundled Goose Ready during a Pi installation", async () => { - const user = userEvent.setup(); - let complete!: (report: HarnessInstallReport) => void; - const pending = new Promise((resolve) => { - complete = resolve; - }); - setupHarnesses("cli-needed", { - installSupported: true, - installPi: () => pending, - }); - const rows = within( - await screen.findByRole("list", { name: "Harnesses" }), - ).getAllByRole("listitem"); - if (!rows[1] || !rows[2]) throw new Error("Missing Harness rows"); - await user.click(within(rows[2]).getByRole("button", { name: "Install" })); - expect(within(rows[1]).queryByRole("button", { name: "Install" })).toBeNull(); - expect(rows[1]).toHaveTextContent("GooseReady"); - complete({ - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/pi-install.log", - output: "Full diagnostic output", - error: - "Installing Pi failed (exit code 1).\nnpm error code E404\nnpm error 404 No match found for version >=0.99.0", - }); - const alert = await screen.findByRole("alert"); - expect(rows[2]).toContainElement(alert); - expect(within(alert).getByText(/Installing Pi failed/)).toBeVisible(); - expect(alert).toHaveTextContent("E404"); - expect(alert).toHaveTextContent("No match found for version >=0.99.0"); - const log = within(alert).getByText("Pi install log"); - expect(log.closest("details")).not.toHaveAttribute("open"); - expect(within(alert).getByText("Full diagnostic output")).not.toBeVisible(); - await user.click(log); - expect(within(alert).getByText("Full diagnostic output")).toBeVisible(); -}); - -it("installs Claude Code with durable progress, failed-step logs, retry and native re-detection", async () => { - const user = userEvent.setup(); - let finish!: (report: HarnessInstallReport) => void; - const pending = new Promise((resolve) => { - finish = resolve; - }); - const failed: HarnessInstallReport = { - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/claude-install.log", - output: "npm error EACCES", - error: "Installing Claude Code failed", - }; - const install = vi - .fn() - .mockReturnValueOnce(pending) - .mockResolvedValueOnce({ ...failed, ready: true, error: null }); - const { control, fixture } = setupHarnesses("cli-needed", { - installSupported: true, - installPi: vi.fn(), - claude: { - status: "adapter-needed", - installSupported: true, - loginCommand: "'/fixture/claude' auth login", - }, - installClaude: install, - }); - const row = () => - within(screen.getByRole("listitem", { name: "Claude Code harness" })); - await screen.findByRole("listitem", { name: "Claude Code harness" }); - await user.click(row().getByRole("button", { name: "Install" })); - try { - expect(await row().findByRole("status")).toHaveTextContent( - "Installing Claude Code", - ); - expect(screen.getByRole("button", { name: "Check again" })).toBeDisabled(); - const piRow = screen.getByText("Pi", { exact: true }).closest("li"); - if (!piRow) throw new Error("Missing Pi row"); - expect( - within(piRow).getByRole("button", { name: "Install" }), - ).toBeDisabled(); - cleanup(); - render(, { wrapper: ToastProvider }); - expect(await row().findByRole("status")).toHaveTextContent( - "Installing Claude Code", - ); - } finally { - finish(failed); - } - expect(await row().findByRole("alert")).toHaveTextContent( - "Installing Claude Code failed", - ); - await user.click(row().getByText("Claude Code install log")); - expect(row().getByText("npm error EACCES")).toBeVisible(); - expect(row().getByText("/fixture/claude-install.log")).toBeVisible(); - if (!fixture.data.claudeSetup) throw new Error("Missing Claude fixture"); - fixture.data.claudeSetup.status = "ready"; - await user.click(row().getByRole("button", { name: "Install" })); - expect(await row().findByText("Sign-in unconfirmed")).toBeVisible(); - expect(row().queryByRole("alert")).toBeNull(); - expect(row().queryByRole("button", { name: "Install" })).toBeNull(); - await user.click(row().getByText("Sign in to Claude Code")); - expect(row().getByText("'/fixture/claude' auth login")).toBeVisible(); - expect(row().getByText(/Couldn’t confirm Claude Code sign-in/)).toBeVisible(); - expect(install).toHaveBeenCalledTimes(2); -}); - -it.each([false, true] as const)( - "keeps keyboard retry usable and hands off success only while Install owns focus (moved: %s)", - async (moveFocus) => { - const user = userEvent.setup(); - let finish!: (report: HarnessInstallReport) => void; - const pending = new Promise((resolve) => { - finish = resolve; - }); - const report: HarnessInstallReport = { - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/claude-install.log", - output: "npm error EACCES", - error: "Installation failed", - }; - const install = vi.fn(() => pending); - const { fixture } = setupHarnesses("ready", { - claude: { - status: "cli-needed", - installSupported: true, - loginCommand: "'/fixture/claude' auth login", - }, - installClaude: install, - checkClaudeAuth: async () => !moveFocus, - }); - const row = within( - await screen.findByRole("listitem", { name: "Claude Code harness" }), - ); - const button = row.getByRole("button", { name: "Install" }); - for (let i = 0; i < 10 && document.activeElement !== button; i++) { - await user.tab(); - } - expect(button).toHaveFocus(); - await user.keyboard("{Enter}"); - try { - expect(await row.findByText(/Installing Claude Code and/)).toBeVisible(); - expect(button).toHaveFocus(); - await user.keyboard("{Enter}"); - expect(install).toHaveBeenCalledTimes(1); - } finally { - await act(async () => { - finish(report); - await pending; - }); - } - expect(await row.findByRole("alert")).toHaveTextContent( - "Installation failed", - ); - expect(button).toHaveFocus(); - expect(button).not.toBeDisabled(); - const retry = new Promise((resolve) => { - finish = resolve; - }); - install.mockReturnValue(retry); - await user.keyboard("{Enter}"); - await waitFor(() => expect(install).toHaveBeenCalledTimes(2)); - let next: Element | null = null; - try { - expect(button).toHaveFocus(); - if (moveFocus) { - next = screen.getByRole("button", { name: "Add harness" }); - for (let i = 0; i < 10 && document.activeElement !== next; i++) { - await user.tab(); - } - expect(next).toHaveFocus(); - } - if (!fixture.data.claudeSetup) throw new Error("Missing Claude fixture"); - fixture.data.claudeSetup.status = "ready"; - } finally { - await act(async () => { - finish({ ...report, ready: true, error: null }); - await retry; - }); - } - const status = await row.findByText(moveFocus ? "Sign-in needed" : "Ready"); - expect(row.queryByRole("button", { name: "Install" })).toBeNull(); - if (moveFocus) expect(next).toHaveFocus(); - else expect(status).toHaveFocus(); - }, -); - -it("offers manual Claude setup and disables creation when its tools are missing", async () => { - const user = userEvent.setup(); - const { control, fixture } = setupHarnesses("ready", { - claude: { - status: "cli-needed", - installSupported: false, - loginCommand: null, - }, - installClaude: vi.fn(), - }); - const row = within( - await screen.findByRole("listitem", { name: "Claude Code harness" }), - ); - expect(row.queryByRole("button", { name: "Install" })).toBeNull(); - await user.click(row.getByText("Manual Claude Code setup")); - expect( - row.getByText(/npm install -g @anthropic-ai\/claude-code/), - ).toBeVisible(); - expect( - row.getByText(/npm install -g @agentclientprotocol\/claude-agent-acp/), - ).toBeVisible(); - // Exercise creation's real picker with the same native snapshot as Settings. - fixture.data.harnessOptions?.push({ - command: "claude-agent-acp", - label: "Claude Code", - available: false, - status: "cli-needed", - providers: [], - defaultArgs: [], - }); - await control.refresh(); - cleanup(); - render( - {}} - />, - ); - await user.click(screen.getByRole("combobox", { name: "Harness" })); - expect( - await screen.findByRole("option", { name: "Buzz Agent" }), - ).toBeVisible(); - expect( - screen.getByRole("option", { name: "Claude Code (install first)" }), - ).toHaveAttribute("aria-disabled", "true"); - expect(control.snapshot().data?.claudeSetup?.status).toBe("cli-needed"); -}); - -it.each([true, false, null] as const)( - "shows Claude setup guidance only when sign-in needs attention (%s)", - async (signedIn) => { - const user = userEvent.setup(); - const checkClaudeAuth = vi.fn().mockResolvedValue(signedIn); - setupHarnesses("ready", { - claude: { - status: "ready", - installSupported: true, - loginCommand: "'/fixture/claude' auth login", - }, - checkClaudeAuth, - }); - await screen.findByRole("listitem", { name: "Claude Code harness" }); - const row = () => - within(screen.getByRole("listitem", { name: "Claude Code harness" })); - if (signedIn === true) { - await waitFor(() => expect(checkClaudeAuth).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(row().getByText("Ready")).toBeVisible()); - expect(row().queryByText("Sign in to Claude Code")).toBeNull(); - expect( - row().queryByText(/Claude Code and its ACP adapter are installed/), - ).toBeNull(); - } else { - expect( - await row().findByText( - signedIn === false ? "Sign-in needed" : "Sign-in unconfirmed", - ), - ).toBeVisible(); - expect(row().queryByText("Ready")).toBeNull(); - await user.click(row().getByText("Sign in to Claude Code")); - expect(row().getByText("'/fixture/claude' auth login")).toBeVisible(); - } - checkClaudeAuth.mockResolvedValue(true); - await user.click(screen.getByRole("button", { name: "Check again" })); - await waitFor(() => expect(checkClaudeAuth).toHaveBeenCalledTimes(2)); - await waitFor(() => expect(row().getByText("Ready")).toBeVisible()); - expect(row().queryByText("Sign in to Claude Code")).toBeNull(); - }, -); - -it("ignores a late Claude auth check after Check again and retains an install failure on a ready row", async () => { - const user = userEvent.setup(); - let finish!: (signedIn: boolean) => void; - const pending = new Promise((resolve) => { - finish = resolve; - }); - const checkClaudeAuth = vi - .fn() - .mockReturnValueOnce(pending) - .mockResolvedValue(false); - const { control } = setupHarnesses("ready", { - claude: { - status: "ready", - installSupported: true, - loginCommand: "'/fixture/claude' auth login", - }, - checkClaudeAuth, - installClaude: vi.fn().mockResolvedValue({ - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/claude-install.log", - output: "npm error EACCES", - error: "Update failed; previous tools retained", - }), - }); - await screen.findByRole("listitem", { name: "Claude Code harness" }); - const row = () => - within(screen.getByRole("listitem", { name: "Claude Code harness" })); - try { - await waitFor(() => expect(checkClaudeAuth).toHaveBeenCalledTimes(1)); - expect(row().getByText("Checking sign-in…")).toBeVisible(); - expect(row().queryByText("Ready")).toBeNull(); - await user.click(screen.getByRole("button", { name: "Check again" })); - expect(await row().findByText("Sign-in needed")).toBeVisible(); - } finally { - await act(async () => { - finish(true); - await pending; - }); - } - expect(row().getByText("Sign-in needed")).toBeVisible(); - checkClaudeAuth.mockResolvedValue(true); - await act(async () => { - await control.installClaude?.(); - }); - expect(await row().findByText("Ready")).toBeVisible(); - expect(row().getByRole("alert")).toHaveTextContent( - "Update failed; previous tools retained", - ); - expect(row().queryByText("Sign in to Claude Code")).toBeNull(); - await user.click(row().getByText("Claude Code install log")); - expect(row().getByText("npm error EACCES")).toBeVisible(); -}); diff --git a/src/app/AgentSettings.tsx b/src/app/AgentSettings.tsx index 0b3a1ab9d..6422af27c 100644 --- a/src/app/AgentSettings.tsx +++ b/src/app/AgentSettings.tsx @@ -1,695 +1,28 @@ -import { SettingsGroup } from "../shared/design-system/ui/SettingsGroup"; -import { - useCallback, - useEffect, - useRef, - useState, - useSyncExternalStore, -} from "react"; -import { - harnessPresets, - harnessKind, - harnessPreset, -} from "../features/agents/harness-presets"; -import { PresetSetupHint } from "../features/agents/PresetSetupHint"; -import type { AgentControl } from "../features/agents/control"; +import { useState, type ReactNode } from "react"; import { setRememberAgentsPreference, useRememberAgentsPreference, } from "../features/messages/mention-preferences"; -import { - ArrowsClockwiseIcon, - ArrowSquareOutIcon, - CodexLogoIcon, - CopyIcon, - GooseLogoIcon, - HermesLogoIcon, - PiLogoIcon, - PlusIcon, - QuestionIcon, - RobotIcon, - TerminalWindowIcon, -} from "../shared/design-system/icons"; import { Header, InlineHeader } from "../shared/design-system/ui/Header"; import { Button } from "../shared/design-system/ui/Button"; -import { Dialog } from "../shared/design-system/ui/Dialog"; -import { NavigationItem } from "../shared/design-system/ui/NavigationItem"; -import { IconButton } from "../shared/design-system/ui/IconButton"; -import { PreferenceRow } from "../shared/design-system/ui/PreferenceRow"; +import { SettingsGroup } from "../shared/design-system/ui/SettingsGroup"; import { SwitchPreferenceRow } from "../shared/design-system/ui/SwitchPreferenceRow"; - import { ToastNotice } from "../shared/design-system/ui/Toast"; -import { Tooltip } from "../shared/design-system/ui/Tooltip"; -import type { ReactNode } from "react"; -import { AgentDefaultsCard } from "./AgentDefaultsCard"; -import { ClaudeHarnessSetup } from "./ClaudeHarnessSetup"; -import styles from "./AgentSettings.module.css"; - -const acpHint = - "Buzz talks to harnesses through the Agent Client Protocol (ACP). Goose ships with Buzz. Pi needs the buzz-pi-acp adapter. Hermes Agent uses its own ACP launcher and sign-in."; -const piCommand = "npm install -g '@earendil-works/pi-coding-agent@>=0.99.0'"; -const adapterCommand = - "npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#72015de'"; -const labels = { - ready: "Ready", - "cli-needed": "CLI needed", - "adapter-needed": "Adapter needed", - "check-needed": "Check needed", - "not-enabled": "Not enabled", -} as const; -// Artwork only; native harnessOptions still own availability and configuration. -const harnessIcons: Record = { - "buzz-agent": , - codex: , - goose: , - hermes: , - pi: , -}; -const codexAdapterCommand = - "npm install -g @agentclientprotocol/codex-acp@2.1.1"; -const commands = [ - ["Pi", "Install Pi", piCommand], - ["Adapter", "Install the ACP adapter", adapterCommand], -] as const; export function AgentSettings({ - control, - active = true, archive, + active = true, }: { - control: AgentControl; - active?: boolean; archive?: ReactNode; + active?: boolean; }) { const preference = useRememberAgentsPreference(); const [error, setError] = useState(null); - const [checking, setChecking] = useState(false); - const [authCheck, setAuthCheck] = useState(0); - const [catalogOpen, setCatalogOpen] = useState(false); - const [selectedPresetId, setSelectedPresetId] = useState(); - const addHarnessRef = useRef(null); - const [copyMessage, setCopyMessage] = useState(""); - const copyAttempt = useRef(0); - const state = useSyncExternalStore(control.subscribe, control.snapshot); - const { - installing: installingPi, - report: piResult, - error: piError, - } = state.piInstall ?? { installing: false, report: null, error: null }; - useEffect(() => { - if (active) void control.refresh(); - }, [active, control]); - const options = state.data?.harnessOptions; - const coreHarnesses = (["buzz-agent", "goose", "pi"] as const).map((id) => - options?.find((option) => harnessKind(option.command) === id), - ); - const presets = - options?.filter( - (option) => - !!harnessPreset(option.command) && - harnessKind(option.command) !== "claude", - ) ?? []; - const setup = - harnessPresets.find((preset) => preset.id === selectedPresetId) ?? - harnessPresets[0]; - // Native owns availability; registry metadata also works on older snapshots. - const selected = options?.find( - (option) => harnessPreset(option.command) === setup, - ); - const presetLabel = selected?.label ?? setup?.label ?? "Harness"; - const codexOption = options?.find((option) => option.id === "codex"); - const harnesses = [ - ...coreHarnesses, - ...presets.filter((option) => option.available), - ...(codexOption ? [codexOption] : []), - ]; - const available = coreHarnesses.every((option) => !!option?.status); - const pi = coreHarnesses[2]; - const codexInstall = state.codexInstall; - const codexToolsReady = codexOption?.status === "ready"; - const [codexAuth, setCodexAuth] = useState( - "checking", - ); - // Like Claude Code: read the existing CLI login only once its tools are found. - // biome-ignore lint/correctness/useExhaustiveDependencies: Check again and install completion must trigger this read even when tool presence is unchanged. - useEffect(() => { - if (!active || !codexToolsReady || codexInstall?.installing) return; - let current = true; - setCodexAuth("checking"); - void (control.checkCodexAuth?.() ?? Promise.resolve(null)) - .catch(() => null) - .then((result) => { - if (current) setCodexAuth(result); - }); - return () => { - current = false; - }; - }, [ - active, - codexToolsReady, - codexInstall?.installing, - codexInstall?.report, - authCheck, - control, - ]); - // Like Claude Code: keep keyboard focus when a successful install removes Install. - const codexStatusRef = useRef(null); - const codexInstallRef = useCallback((button: HTMLButtonElement | null) => { - if (!button) return; - return () => { - if (button.ownerDocument.activeElement === button) { - codexStatusRef.current?.focus(); - } - }; - }, []); - const offerCodexInstall = - !!control.installCodex && - !!codexOption?.installSupported && - codexOption.status === "adapter-needed"; - const codexStatus = !codexToolsReady - ? codexOption?.status - ? labels[codexOption.status] - : "Unknown" - : codexAuth === "checking" - ? "Checking sign-in…" - : codexAuth === true - ? "Ready" - : codexAuth === false - ? "Sign-in needed" - : "Sign-in unconfirmed"; - const checkDisabled = - state.status === "unavailable" || - state.busy || - installingPi || - state.claudeInstall?.installing || - codexInstall?.installing; - const checkAgain = () => { - setChecking(true); - void control.refresh().finally(() => { - setChecking(false); - setAuthCheck((value) => value + 1); - }); - }; const change = (enabled: boolean) => setError(setRememberAgentsPreference(enabled)); - const copy = async (name: string, command: string) => { - const attempt = ++copyAttempt.current; - try { - await navigator.clipboard.writeText(command); - if (attempt === copyAttempt.current) - setCopyMessage(`${name} command copied.`); - } catch { - if (attempt === copyAttempt.current) - setCopyMessage( - `Couldn’t copy the ${name} command. Select it to copy manually.`, - ); - } - }; return (
-
- - - - - - - } - /> - - {state.status === "unavailable" ? ( -

- Harness detection requires the desktop app. -

- ) : state.status === "loading" || state.status === "idle" ? ( -

- Checking harnesses… -

- ) : !available ? ( -

- {state.status === "error" - ? "Couldn’t check harnesses. Select Check again to retry." - : "Update the desktop app to check harnesses."} -

- ) : ( - <> - {state.status === "error" && ( -

- Couldn’t confirm harnesses. Showing the last check; try Check - again. -

- )} -
    - {harnesses.map((option) => ( -
  • -
    - - {option && - (harnessIcons[ - harnessKind(option.command) ?? option.id ?? "" - ] ?? ( - - ))} - {option?.label} - - - - {option?.id === "codex" - ? codexStatus - : option?.status - ? labels[option.status] - : "Unknown"} - - {option && - harnessKind(option.command) === "pi" && - (option.status !== "ready" || - option.updateSupported) && - option.installSupported && - control.installPi && - !piResult?.ready && ( - - )} - {option?.id === "codex" && offerCodexInstall && ( - - )} - -
    - {option && - option.status !== "ready" && - harnessPreset(option.command) && ( -
    -

    - Uses the default model and credentials configured in{" "} - {option.label}. Install and update the harness - yourself, then use Check again. -

    -
    - Manual {option.label} setup -
    - -
    -
    -
    - )} - {option?.id === "codex" && - (codexInstall?.installing || - codexInstall?.report?.error || - codexInstall?.error) && ( -
    - {codexInstall.installing ? ( -

    - Installing Node.js and the Codex ACP adapter… -

    - ) : ( -
    -

    - {codexInstall.report?.error || - codexInstall.error} -

    - {codexInstall.report && ( -
    - - Codex ACP adapter install log - -

    - {codexInstall.report.logPath} -

    -
    -                                    {codexInstall.report.output ||
    -                                      "No output was recorded."}
    -                                  
    -
    - )} -
    - )} -
    - )} - {option?.id === "codex" && - offerCodexInstall && - !codexInstall?.installing && ( -
    -

    - Click Install. Buzz installs Node.js and the Codex - ACP adapter for you. Your Codex CLI and sign-in are - left untouched. -

    -
    - Manual Codex ACP adapter setup - - {codexAdapterCommand} - -
    -
    - )} - {option?.id === "codex" && - (option.status === "cli-needed" || - (codexToolsReady && - (codexAuth === false || codexAuth === null))) && ( -

    - {option.status === "cli-needed" - ? "Install the Codex CLI, then use Check again." - : "Run codex login in your terminal, then use Check again."} -

    - )} - {option && - harnessKind(option.command) === "pi" && - (installingPi || - piResult?.ready || - piResult?.error || - piError || - pi?.status !== "ready") && ( -
    - {installingPi && ( -

    - Installing Pi and its ACP adapter… -

    - )} - {!installingPi && - piResult?.ready && - pi?.status === "ready" && ( -

    - Pi and its adapter are up to date. Restart - running Pi agents to use them. Restarted{" "} - {piResult.restarted} waiting agents. - {piResult.restartFailures > 0 && - ` ${piResult.restartFailures} agents could not restart; check Agents.`} -

    - )} - {!installingPi && (piResult?.error || piError) && ( -
    -

    - {piResult?.error || piError} -

    - {piResult && ( -
    - Pi install log -

    - {piResult.logPath} -

    -
    -                                    {piResult.output ||
    -                                      "No output was recorded."}
    -                                  
    -
    - )} -
    - )} - {pi?.status !== "ready" && ( -
    -

    - {pi?.installSupported && control.installPi - ? "Click Install. Buzz installs Node.js, Pi, and its ACP adapter for you." - : "Use Manual setup on this device, then click Check again."}{" "} - Your existing CLI setup and sign-in are left - untouched. -

    -
    - Manual setup -
    -
      -
    1. - -
    2. - {commands.map(([name, title, command]) => ( -
    3. - - {command} - - } - trailing={ - - - } - /> -
    4. - ))} -
    5. - -
    6. -
    - {copyMessage && ( -

    - {copyMessage} -

    - )} -
    -
    -
    - )} -
    - )} -
  • - ))} - -
- - - )} -
-
- - - {archive}
@@ -712,31 +45,3 @@ export function AgentSettings({
); } - -function PresetSetup({ - label, - setup, -}: { - label: string; - setup: { setupUrl: string; setupHint: string } | undefined; -}) { - if (!setup) return null; - return ( -
-

- Follow the{" "} - - {label} setup guide - {" "} - for installation instructions.{" "} - -

-

Use Check again to refresh the installation status.

-
- ); -} diff --git a/src/app/App.profile.test.tsx b/src/app/App.profile.test.tsx index 5262fce99..71dfbfd01 100644 --- a/src/app/App.profile.test.tsx +++ b/src/app/App.profile.test.tsx @@ -13,7 +13,6 @@ import { afterEach, expect, it, vi } from "vitest"; import { finalizeEvent, getPublicKey } from "nostr-tools"; import { StrictMode } from "react"; import { App } from "./App"; -import { requestSnapshotPreview } from "../features/agents/snapshot-preview"; import { createServices, type AppServices } from "./services"; import { matchesEvent } from "../features/relay/projection"; import type { ReadFilter } from "../features/relay/events"; @@ -284,33 +283,3 @@ it("opens the viewer's profile from New message without discarding recipients or await user.keyboard(" and keep editing"); expect(editor).toHaveTextContent("Keep this unsent draft and keep editing"); }); - -it("mounts the received snapshot preview host without automatically importing", async () => { - const user = await setup(); - await screen.findByRole("button", { name: "Your profile" }); - const current = services; - if (!current) throw new Error("Missing services"); - const connection = current.relay.snapshot(); - if (connection.status !== "ready") throw new Error("Missing ready session"); - const create = vi.fn(); - Object.assign(current.agentControl, { create }); - act(() => - requestSnapshotPreview(connection.session, { - url: `${origin}/media/${"a".repeat(64)}.png`, - name: "helper.agent.png", - kind: "image", - }), - ); - const dialog = await screen.findByRole("dialog", { - name: "Preview snapshot", - }); - expect(dialog).toHaveTextContent("Preview snapshot"); - expect(create).not.toHaveBeenCalled(); - await user.click(within(dialog).getByRole("button", { name: "Cancel" })); - await waitFor(() => - expect( - screen.queryByRole("dialog", { name: "Preview snapshot" }), - ).not.toBeInTheDocument(), - ); - expect(create).not.toHaveBeenCalled(); -}); diff --git a/src/app/App.tsx b/src/app/App.tsx index 63cab60d1..41672f0e6 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -6,9 +6,6 @@ import { ChannelNavigationProvider } from "../features/channel-navigation/Channe import { Agents2Context } from "../features/agents2/react"; import { ToastProvider } from "../shared/design-system/ui/Toast"; import { Button } from "../shared/design-system/ui/Button"; -import { AgentWakeNotice } from "../features/agents/AgentWakeNotice"; -import { AgentUpdateReview } from "../bundled/agents/AgentUpdateReview"; -import { SnapshotPreview } from "../bundled/agents/SnapshotPreview"; import { UpdateNotice } from "../features/updates/UpdateNotice"; import { useEffect, useSyncExternalStore } from "react"; import { registerAppShortcuts } from "./shortcuts"; @@ -242,15 +239,6 @@ function ConnectedApp({ services }: { services: AppServices }) { startup === "ready" && route.page?.layout === "workspace" } > - - - {startup === "recovery" && !settings ? ( @@ -286,7 +274,6 @@ function ConnectedApp({ services }: { services: AppServices }) { shortcuts={services.shortcuts} shortcutBindings={services.shortcutBindings} notifications={services.notifications} - agentControl={services.agentControl} updates={services.updates} navigation={route.request} navigationPane diff --git a/src/app/ClaudeHarnessSetup.tsx b/src/app/ClaudeHarnessSetup.tsx deleted file mode 100644 index c6800e805..000000000 --- a/src/app/ClaudeHarnessSetup.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import type { - AgentControl, - AgentControlState, -} from "../features/agents/control"; -import { ClaudeLogoIcon } from "../shared/design-system/icons"; -import { Button } from "../shared/design-system/ui/Button"; -import styles from "./AgentSettings.module.css"; - -const cliCommand = "npm install -g @anthropic-ai/claude-code@2.1.289"; -const adapterCommand = - "npm install -g @agentclientprotocol/claude-agent-acp@0.85.1"; - -export function ClaudeHarnessSetup({ - control, - state, - active = true, -}: { - control: AgentControl; - state: AgentControlState; - active?: boolean; -}) { - const setup = state.data?.claudeSetup; - - const { installing, report, error } = state.claudeInstall ?? { - installing: false, - report: null, - error: null, - }; - const toolsReady = setup?.status === "ready"; - const [auth, setAuth] = useState("checking"); - const statusRef = useRef(null); - const installRef = useCallback((button: HTMLButtonElement | null) => { - if (!button) return; - return () => { - if (button.ownerDocument.activeElement === button) { - statusRef.current?.focus(); - } - }; - }, []); - // A new install result also rechecks auth if React batches a fast install. - // biome-ignore lint/correctness/useExhaustiveDependencies: install completion must trigger this read even when tool presence is unchanged. - useEffect(() => { - if (!active || !toolsReady || installing) return; - let current = true; - setAuth("checking"); - void (control.checkClaudeAuth?.() ?? Promise.resolve(null)) - .catch(() => null) - .then((result) => { - if (current) setAuth(result); - }); - return () => { - current = false; - }; - }, [active, toolsReady, installing, report, control]); - if (!setup) return null; - const ready = toolsReady && auth === true; - const needsGuidance = !toolsReady || auth === false || auth === null; - const showDetails = installing || report?.error || error || needsGuidance; - return ( -
  • -
    - - - Claude Code - - - - {ready - ? "Ready" - : toolsReady - ? auth === "checking" - ? "Checking sign-in…" - : auth === false - ? "Sign-in needed" - : "Sign-in unconfirmed" - : setup.status === "cli-needed" - ? "CLI needed" - : "Adapter needed"} - - {!toolsReady && setup.installSupported && control.installClaude && ( - - )} - -
    - {showDetails && ( -
    - {installing && ( -

    Installing Claude Code and its ACP adapter…

    - )} - {!installing && (report?.error || error) && ( -
    -

    - {report?.error || error} -

    - {report && ( -
    - Claude Code install log -

    {report.logPath}

    -
    -                    {report.output || "No output was recorded."}
    -                  
    -
    - )} -
    - )} - {!toolsReady && ( - <> -

    - {setup.installSupported && control.installClaude - ? "Click Install. Buzz installs Node.js, Claude Code, and its ACP adapter for you." - : "Use Manual setup on this device, then click Check again."} -

    -
    - Manual Claude Code setup -
    -

    - Install Node.js 22 or newer, then run these in your - terminal: -

    - {[cliCommand, adapterCommand].map((command) => ( - - {command} - - ))} -

    Use Check again to refresh the installation status.

    -
    -
    - - )} - {toolsReady && auth === null && ( -

    - Couldn’t confirm Claude Code sign-in. Use Check again to retry, or - sign in below. -

    - )} - {needsGuidance && setup.loginCommand && ( -
    - Sign in to Claude Code -

    - Run this in your terminal and follow Claude’s sign-in steps: -

    - - {setup.loginCommand} - -
    - )} -
    - )} -
  • - ); -} diff --git a/src/app/Settings.tsx b/src/app/Settings.tsx index ee7290f21..cbff4f34d 100644 --- a/src/app/Settings.tsx +++ b/src/app/Settings.tsx @@ -27,7 +27,6 @@ import { ShortcutSettings } from "./ShortcutSettings"; import { DeveloperSettings } from "./DeveloperSettings"; import { ArchiveSettings } from "./ArchiveSettings"; import { AgentSettings } from "./AgentSettings"; -import type { AgentControl } from "../features/agents/control"; import type { SettingsCards } from "../features/settings/service"; import { OwnedContribution } from "../plugins/OwnedContribution"; import type { Updates } from "../features/updates/updates"; @@ -43,14 +42,12 @@ export function Settings({ shortcuts, shortcutBindings, notifications, - agentControl, updates, navigation, onSection, navigationPane = false, }: { cards: SettingsCards; - agentControl: AgentControl; plugins: PluginManager; communities: Communities; identity?: Identity | undefined; @@ -163,7 +160,6 @@ export function Settings({