From ed1480b3147570fd6a9577e550f71e5990755bf4 Mon Sep 17 00:00:00 2001 From: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Date: Thu, 8 Oct 2026 18:33:22 -0400 Subject: [PATCH 01/43] fix(agents): strip baked team instructions from imported prompts Old Buzz once stored team instructions inside an agent's prompt behind an exact delimiter. The runtime now adds the team section itself, so an imported copy delivered the text twice. Only the imported copy is changed; the preview flags affected agents without exposing the prompt. Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- crates/agent-controller/src/import.rs | 20 ++++++- crates/agent-controller/src/import/tests.rs | 59 +++++++++++++++++++++ src/bundled/agents/AgentImport.tsx | 12 +++++ src/features/agents/control.ts | 5 +- 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/crates/agent-controller/src/import.rs b/crates/agent-controller/src/import.rs index e3882501d..8aa946450 100644 --- a/crates/agent-controller/src/import.rs +++ b/crates/agent-controller/src/import.rs @@ -47,6 +47,8 @@ pub struct Candidate { pub pubkey: String, pub relay_url: String, pub name: String, + /// The imported prompt drops a team section old Buzz baked into it. + pub strips_team_instructions: bool, } /// Reviewed text only. Never project legacy environment, commands, arguments, /// credentials, paths, owner authorization or retained source records for cloning. @@ -98,7 +100,7 @@ impl Imports { let definition = source_definition(&data, record)?; Ok(CloneSettings { name: string(record, "name").into(), - system_prompt: string(definition, "system_prompt").into(), + system_prompt: imported_prompt(string(definition, "system_prompt")).into(), }) } pub fn discard(&mut self) { @@ -141,6 +143,10 @@ impl Imports { pubkey: key.into(), relay_url: relay.clone(), name: string(record, "name").into(), + strips_team_instructions: source_definition(&data, record).is_ok_and(|d| { + imported_prompt(string(d, "system_prompt")).len() + != string(d, "system_prompt").len() + }), }); } self.sequence = self @@ -359,6 +365,16 @@ fn source_definition<'a>(data: &'a Source, record: &'a Value) -> Result<&'a Valu .ok_or("Linked agent definition is missing; source left unchanged")? }) } +/// Old Buzz once baked team instructions into stored prompts behind this exact +/// producer boundary. The runtime now adds the team section itself, so a copied +/// suffix would reach the agent twice. Byte-exact: a bare `---` or a heading +/// elsewhere is the author's own content. Applied to the imported copy only. +const BAKED_TEAM_DELIMITER: &str = "\n\n---\n# Team Instructions\n"; +fn imported_prompt(prompt: &str) -> &str { + prompt + .rfind(BAKED_TEAM_DELIMITER) + .map_or(prompt, |at| &prompt[..at]) +} fn resolve(data: &Source, record: &Value, workspace: &Path, destination: &str) -> Result { let definition = source_definition(data, record)?; let fallback = |key| { @@ -426,7 +442,7 @@ fn resolve(data: &Source, record: &Value, workspace: &Path, destination: &str) - pubkey, relay_url, name: string(record, "name").into(), - system_prompt: string(definition, "system_prompt").into(), + system_prompt: imported_prompt(string(definition, "system_prompt")).into(), session_policy: None, session_policy_inherit: false, workspace: workspace.display().to_string(), diff --git a/crates/agent-controller/src/import/tests.rs b/crates/agent-controller/src/import/tests.rs index d184a9220..e1ff41fcd 100644 --- a/crates/agent-controller/src/import/tests.rs +++ b/crates/agent-controller/src/import/tests.rs @@ -1098,3 +1098,62 @@ fn import_excludes_overlapping_destinations_before_credentials_through_commit() assert_eq!(fs::read(dest.path().join("agents.json")).unwrap(), before); assert_eq!(store.agents().unwrap().len(), 1); } +#[test] +fn imported_prompt_strips_only_the_last_exact_baked_team_suffix() { + let baked = "\n\n---\n# Team Instructions\n"; + assert_eq!(imported_prompt(&format!("role{baked}team")), "role"); + assert_eq!( + imported_prompt(&format!("role{baked}old{baked}team")), + format!("role{baked}old") + ); + assert_eq!(imported_prompt(&format!("{baked}team")), ""); + for own in [ + "no delimiter at all", + "role\n\n---\nauthor rule", + "role\n---\n# Team Instructions\nauthor heading", + "# Team Instructions\nauthor heading", + ] { + assert_eq!(imported_prompt(own), own); + } +} +#[test] +fn import_strips_baked_team_suffix_from_the_copy_and_flags_it_in_preview() { + let old = tempfile::tempdir().unwrap(); + let dest = tempfile::tempdir().unwrap(); + source(old.path()); + let path = old + .path() + .join(LegacySource::Installed.app_directory()) + .join("agents/managed-agents.json"); + let mut records: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + records[0]["system_prompt"] = json!("role\n\n---\n# Team Instructions\nbaked team"); + fs::write(&path, serde_json::to_vec(&records).unwrap()).unwrap(); + let before = fs::read(&path).unwrap(); + let mut imports = Imports::default(); + let preview = team_preview(&mut imports, old.path(), dest.path()); + assert!(preview.candidates[0].strips_team_instructions); + assert!(!serde_json::to_string(&preview) + .unwrap() + .contains("baked team")); + let clone = Imports::clone_settings(LegacySource::Installed, old.path().into(), PUB).unwrap(); + assert_eq!(clone.system_prompt, "role"); + let mut store = Store::open(dest.path().into()).unwrap(); + imports + .commit( + &preview.token, + &[preview.candidates[0].id.clone()], + &mut store, + &Memory::default(), + ) + .unwrap(); + assert_eq!(store.agents().unwrap()[0].system_prompt, "role"); + assert_eq!(fs::read(&path).unwrap(), before); +} +#[test] +fn preview_does_not_flag_a_prompt_without_the_baked_suffix() { + let old = tempfile::tempdir().unwrap(); + let dest = tempfile::tempdir().unwrap(); + source(old.path()); + let preview = team_preview(&mut Imports::default(), old.path(), dest.path()); + assert!(!preview.candidates[0].strips_team_instructions); +} diff --git a/src/bundled/agents/AgentImport.tsx b/src/bundled/agents/AgentImport.tsx index b89c06334..4b77131a9 100644 --- a/src/bundled/agents/AgentImport.tsx +++ b/src/bundled/agents/AgentImport.tsx @@ -12,6 +12,8 @@ import type { } from "../../features/agents/control"; import { Button } from "../../shared/design-system/ui/Button"; +const STRIPPED_TEAM_INSTRUCTIONS = + "Old team instructions saved inside this agent's prompt are left out, so the agent doesn't get them twice."; export function AgentImport({ ref, control, @@ -205,6 +207,11 @@ export function AgentImport({ )} + {candidate?.stripsTeamInstructions && ( +

+ {STRIPPED_TEAM_INSTRUCTIONS} +

+ )}

Stop the agent in the old Buzz installation before starting it here.

@@ -339,6 +346,11 @@ export function AgentImport({

{repair ? "Team instructions not imported" : "Not imported"}

+ {!repair && candidate.stripsTeamInstructions && ( +

+ {STRIPPED_TEAM_INSTRUCTIONS} +

+ )}
Identity

diff --git a/src/features/agents/control.ts b/src/features/agents/control.ts index bed446bcc..e3f8333c9 100644 --- a/src/features/agents/control.ts +++ b/src/features/agents/control.ts @@ -179,7 +179,10 @@ export interface AgentEdit { export interface AgentImportPreview { token: string; sourcePath: string; - candidates: Pick[]; + candidates: (Pick & { + /** The imported prompt drops a team section old Buzz baked into it. */ + stripsTeamInstructions?: boolean; + })[]; warnings: string[]; } export type AgentLogTarget = Pick & { From 12740bdb65ef3e48038beedecbcf1393c57d744d Mon Sep 17 00:00:00 2001 From: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Date: Thu, 8 Oct 2026 18:42:07 -0400 Subject: [PATCH 02/43] test(agents): cover the stripped team instructions notice Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- src/bundled/agents/AgentImport.test.tsx | 86 +++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/bundled/agents/AgentImport.test.tsx b/src/bundled/agents/AgentImport.test.tsx index a05e1b564..6a2bb34b4 100644 --- a/src/bundled/agents/AgentImport.test.tsx +++ b/src/bundled/agents/AgentImport.test.tsx @@ -73,3 +73,89 @@ it.each([false, true])( } }, ); +const notice = /Old team instructions saved inside this agent's prompt/; +const previewWith = ( + f: ReturnType, + candidates: { id: string; name: string; stripsTeamInstructions: boolean }[], +) => { + f.host.previewImport = async (_source, destination) => ({ + token: "fixture-preview", + sourcePath: "/fixture/managed-agents.json", + warnings: [], + candidates: candidates.map((candidate, index) => ({ + ...candidate, + pubkey: String(index + 1).repeat(64), + relayUrl: destination, + })), + }); +}; +const mount = ( + control: ReturnType, + props: Partial[0]> = {}, +) => + render( + + + + + + + , + ); +it("notes stripped team instructions only for agents whose prompt is cut", async () => { + const f = controlFixture(); + previewWith(f, [ + { id: "cut", name: "Cut", stripsTeamInstructions: true }, + { id: "kept", name: "Kept", stripsTeamInstructions: false }, + ]); + const control = createAgentControl(f.host); + const mounted = mount(control); + try { + await screen.findByRole("button", { name: "Import Kept" }); + expect(screen.getAllByText(notice)).toHaveLength(1); + const cut = screen.getByRole("button", { + name: "Import Cut", + }).parentElement; + expect(cut).toHaveTextContent(notice); + mounted.unmount(); + mount(control, { selectedPubkey: "2".repeat(64), selectedName: "Kept" }); + await screen.findByRole("heading", { name: "Import Kept?" }); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Import agent" }), + ).toBeEnabled(), + ); + expect(screen.queryByText(notice)).toBeNull(); + cleanup(); + mount(control, { selectedPubkey: "1".repeat(64), selectedName: "Cut" }); + expect(await screen.findByText(notice)).toBeVisible(); + } finally { + control.dispose(); + } +}); +it("hides the stripped-instructions notice on the repair action", async () => { + const f = controlFixture(); + previewWith(f, [ + { id: f.agent.id, name: "Repair me", stripsTeamInstructions: true }, + ]); + const control = createAgentControl(f.host); + const mounted = mount(control, { + managedAgents: [{ ...f.agent, needsTeamImport: true }], + }); + try { + await screen.findByRole("button", { + name: "Repair team import for Repair me", + }); + expect(screen.queryByText(notice)).toBeNull(); + } finally { + mounted.unmount(); + control.dispose(); + } +}); From 9a670462ecb578658652ea6251059b0ecb8ef1d8 Mon Sep 17 00:00:00 2001 From: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Date: Thu, 8 Oct 2026 18:50:21 -0400 Subject: [PATCH 03/43] fix(agents): clean baked team text from earlier beta imports Agents imported before the import-time cut still carry old Buzz's team section in their saved prompt, so team Save would deliver it twice. Opening the store now cuts it from beta imports only and bumps their revision. Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- crates/agent-controller/src/import.rs | 2 +- crates/agent-controller/src/store.rs | 28 +++++++++++++++- crates/agent-controller/src/store/tests.rs | 39 ++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/agent-controller/src/import.rs b/crates/agent-controller/src/import.rs index 8aa946450..d31e9aa41 100644 --- a/crates/agent-controller/src/import.rs +++ b/crates/agent-controller/src/import.rs @@ -370,7 +370,7 @@ fn source_definition<'a>(data: &'a Source, record: &'a Value) -> Result<&'a Valu /// suffix would reach the agent twice. Byte-exact: a bare `---` or a heading /// elsewhere is the author's own content. Applied to the imported copy only. const BAKED_TEAM_DELIMITER: &str = "\n\n---\n# Team Instructions\n"; -fn imported_prompt(prompt: &str) -> &str { +pub(crate) fn imported_prompt(prompt: &str) -> &str { prompt .rfind(BAKED_TEAM_DELIMITER) .map_or(prompt, |at| &prompt[..at]) diff --git a/crates/agent-controller/src/store.rs b/crates/agent-controller/src/store.rs index 34182814d..c9b390588 100644 --- a/crates/agent-controller/src/store.rs +++ b/crates/agent-controller/src/store.rs @@ -136,9 +136,35 @@ impl Store { _lock: lock, importing: Arc::default(), }; - store.read()?; + store.strip_imported_team_suffixes()?; Ok(store) } + /// Imports made before the import-time cut kept old Buzz's baked team + /// section in their saved prompt. Only beta imports carry `imported.global`, + /// so prompts written in this app are never touched. Idempotent. + fn strip_imported_team_suffixes(&self) -> Result<()> { + let mut doc = self.read()?; + let mut changed = false; + for agent in &mut doc.agents { + if agent.imported.get("global").is_none() { + continue; + } + let prompt = crate::import::imported_prompt(&agent.system_prompt); + if prompt.len() == agent.system_prompt.len() { + continue; + } + agent.system_prompt = prompt.to_owned(); + agent.revision = agent + .revision + .checked_add(1) + .ok_or("Agent revision exhausted")?; + changed = true; + } + if changed { + self.write(&doc)?; + } + Ok(()) + } pub(crate) fn reserve_import(&self) -> Result { self.importing .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) diff --git a/crates/agent-controller/src/store/tests.rs b/crates/agent-controller/src/store/tests.rs index a25587a9d..828ffe8b8 100644 --- a/crates/agent-controller/src/store/tests.rs +++ b/crates/agent-controller/src/store/tests.rs @@ -832,3 +832,42 @@ fn existing_agent_save_accepts_international_text_and_crlf_but_rejects_hidden_co invalid.system_prompt = "hidden\u{202e} instructions".into(); assert!(store.save(&agent.id, saved.revision, invalid).is_err()); } +#[test] +fn reopen_strips_baked_team_suffix_from_beta_imports_only_once() { + let dir = tempfile::tempdir().unwrap(); + let baked = "role\n\n---\n# Team Instructions\nold team"; + let agent = |key: &str, prompt: &str, imported: serde_json::Value| { + let mut agent = fixture(); + agent.pubkey = key.repeat(32); + agent.id = agent_id(&agent.pubkey, &agent.relay_url); + agent.system_prompt = prompt.into(); + agent.imported = imported; + agent + }; + let beta = serde_json::json!({"record": {}, "global": {}}); + let mut store = Store::open(dir.path().to_owned()).unwrap(); + store + .insert(vec![ + agent("01", baked, beta.clone()), + agent("02", baked, serde_json::json!({"record": {}})), + agent("03", "role only", beta), + ]) + .unwrap(); + let before = store.agents().unwrap(); + drop(store); + let store = Store::open(dir.path().to_owned()).unwrap(); + let after = store.agents().unwrap(); + assert_eq!(after[0].system_prompt, "role"); + assert_eq!(after[0].revision, before[0].revision + 1); + for i in [1, 2] { + assert_eq!(after[i].system_prompt, before[i].system_prompt); + assert_eq!(after[i].revision, before[i].revision); + } + drop(store); + let again = Store::open(dir.path().to_owned()) + .unwrap() + .agents() + .unwrap(); + assert_eq!(again[0].system_prompt, "role"); + assert_eq!(again[0].revision, after[0].revision); +} From 7b6c43188a9f4421143b2db1324b923040f866d3 Mon Sep 17 00:00:00 2001 From: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Date: Thu, 8 Oct 2026 19:08:33 -0400 Subject: [PATCH 04/43] fix(agents): clean each imported prompt once, without blocking startup Cleaning on every open cut a second delimiter section on the next launch, including from fresh imports. A per-agent mark saved with the cleaned prompt, and set at import, makes it one-time. A failed cleanup write now becomes an inventory warning instead of disabling the whole agent controller. Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- crates/agent-controller/src/import.rs | 2 +- crates/agent-controller/src/store.rs | 46 +++++++--- crates/agent-controller/src/store/tests.rs | 99 +++++++++++++++++----- src-tauri/src/agents.rs | 3 +- src-tauri/src/agents/tests.rs | 26 ++++++ 5 files changed, 139 insertions(+), 37 deletions(-) diff --git a/crates/agent-controller/src/import.rs b/crates/agent-controller/src/import.rs index d31e9aa41..552bc763e 100644 --- a/crates/agent-controller/src/import.rs +++ b/crates/agent-controller/src/import.rs @@ -462,7 +462,7 @@ fn resolve(data: &Source, record: &Value, workspace: &Path, destination: &str) - .get("auth_tag") .and_then(Value::as_str) .map(str::to_owned), - imported: json!({ "record": retained, "definition": if std::ptr::eq(definition, record) { Value::Null } else { definition.clone() }, "global": data.global, "harness": custom, "teamInstructions": team_instructions(data, record)? }), + imported: json!({ "record": retained, "definition": if std::ptr::eq(definition, record) { Value::Null } else { definition.clone() }, "global": data.global, "teamSuffixCleaned": true, "harness": custom, "teamInstructions": team_instructions(data, record)? }), extra: BTreeMap::new(), }) } diff --git a/crates/agent-controller/src/store.rs b/crates/agent-controller/src/store.rs index c9b390588..6f4aed2f3 100644 --- a/crates/agent-controller/src/store.rs +++ b/crates/agent-controller/src/store.rs @@ -7,6 +7,8 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; +/// Marks a beta import whose prompt no longer needs the baked-team cut. +pub(crate) const CLEANED: &str = "teamSuffixCleaned"; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -136,34 +138,50 @@ impl Store { _lock: lock, importing: Arc::default(), }; - store.strip_imported_team_suffixes()?; + store.read()?; Ok(store) } /// Imports made before the import-time cut kept old Buzz's baked team /// section in their saved prompt. Only beta imports carry `imported.global`, - /// so prompts written in this app are never touched. Idempotent. - fn strip_imported_team_suffixes(&self) -> Result<()> { + /// so prompts written in this app are never touched. Each agent is cleaned + /// once: `imported.teamSuffixCleaned` is saved in the same write, and new + /// imports carry it already. Unreadable storage stays fatal; a failed write + /// only returns a warning, so one prompt cannot disable the controller. + pub fn clean_imported_prompts(&mut self) -> Result> { let mut doc = self.read()?; let mut changed = false; for agent in &mut doc.agents { - if agent.imported.get("global").is_none() { + if agent.imported.get("global").is_none() + || agent.imported.get(CLEANED) == Some(&Value::Bool(true)) + { continue; } let prompt = crate::import::imported_prompt(&agent.system_prompt); - if prompt.len() == agent.system_prompt.len() { - continue; + if prompt.len() != agent.system_prompt.len() { + agent.system_prompt = prompt.to_owned(); + agent.revision = agent + .revision + .checked_add(1) + .ok_or("Agent revision exhausted")?; } - agent.system_prompt = prompt.to_owned(); - agent.revision = agent - .revision - .checked_add(1) - .ok_or("Agent revision exhausted")?; + agent.imported[CLEANED] = Value::Bool(true); changed = true; } - if changed { - self.write(&doc)?; + if !changed { + return Ok(None); } - Ok(()) + let Err(error) = self.write(&doc) else { + return Ok(None); + }; + eprintln!("buzz: could not clean imported agent prompts: {error}"); + // The replace may have landed before the error; trust the saved file. + if serde_json::to_value(self.read()?.agents).ok() == serde_json::to_value(&doc.agents).ok() + { + return Ok(None); + } + Ok(Some(format!( + "Could not remove old Buzz team text from imported agent prompts: {error}" + ))) } pub(crate) fn reserve_import(&self) -> Result { self.importing diff --git a/crates/agent-controller/src/store/tests.rs b/crates/agent-controller/src/store/tests.rs index 828ffe8b8..90d3a2d76 100644 --- a/crates/agent-controller/src/store/tests.rs +++ b/crates/agent-controller/src/store/tests.rs @@ -832,42 +832,99 @@ fn existing_agent_save_accepts_international_text_and_crlf_but_rejects_hidden_co invalid.system_prompt = "hidden\u{202e} instructions".into(); assert!(store.save(&agent.id, saved.revision, invalid).is_err()); } +fn beta_agent(key: &str, prompt: &str, imported: serde_json::Value) -> Agent { + let mut agent = fixture(); + agent.pubkey = key.repeat(32); + agent.id = agent_id(&agent.pubkey, &agent.relay_url); + agent.system_prompt = prompt.into(); + agent.imported = imported; + agent +} +const D: &str = "\n\n---\n# Team Instructions\n"; +fn cleaned(dir: &Path) -> (Vec, Option) { + let mut store = Store::open(dir.to_owned()).unwrap(); + let warning = store.clean_imported_prompts().unwrap(); + (store.agents().unwrap(), warning) +} #[test] -fn reopen_strips_baked_team_suffix_from_beta_imports_only_once() { +fn cleanup_cuts_beta_imports_once_at_the_last_delimiter() { let dir = tempfile::tempdir().unwrap(); - let baked = "role\n\n---\n# Team Instructions\nold team"; - let agent = |key: &str, prompt: &str, imported: serde_json::Value| { - let mut agent = fixture(); - agent.pubkey = key.repeat(32); - agent.id = agent_id(&agent.pubkey, &agent.relay_url); - agent.system_prompt = prompt.into(); - agent.imported = imported; - agent - }; + let twice = format!("role{D}old{D}team"); let beta = serde_json::json!({"record": {}, "global": {}}); let mut store = Store::open(dir.path().to_owned()).unwrap(); store .insert(vec![ - agent("01", baked, beta.clone()), - agent("02", baked, serde_json::json!({"record": {}})), - agent("03", "role only", beta), + beta_agent("01", &twice, beta.clone()), + beta_agent("02", &twice, serde_json::json!({"record": {}})), + beta_agent("03", "role only", beta), ]) .unwrap(); let before = store.agents().unwrap(); drop(store); - let store = Store::open(dir.path().to_owned()).unwrap(); - let after = store.agents().unwrap(); - assert_eq!(after[0].system_prompt, "role"); + let (after, warning) = cleaned(dir.path()); + assert_eq!(warning, None); + assert_eq!(after[0].system_prompt, format!("role{D}old")); assert_eq!(after[0].revision, before[0].revision + 1); for i in [1, 2] { assert_eq!(after[i].system_prompt, before[i].system_prompt); assert_eq!(after[i].revision, before[i].revision); } + let (again, _) = cleaned(dir.path()); + assert_eq!(again[0].system_prompt, format!("role{D}old")); + assert_eq!(again[0].revision, after[0].revision); +} +#[test] +fn cleanup_leaves_fresh_imports_and_later_owner_edits_alone() { + let dir = tempfile::tempdir().unwrap(); + let kept = format!("role{D}kept"); + let mut store = Store::open(dir.path().to_owned()).unwrap(); + store + .insert(vec![ + // A fresh import is cut at import time and marked cleaned. + beta_agent( + "01", + &kept, + serde_json::json!({"global": {}, CLEANED: true}), + ), + beta_agent("02", "role", serde_json::json!({"global": {}})), + ]) + .unwrap(); drop(store); - let again = Store::open(dir.path().to_owned()) - .unwrap() - .agents() + let (first, _) = cleaned(dir.path()); + assert_eq!(first[0].system_prompt, kept); + let mut store = Store::open(dir.path().to_owned()).unwrap(); + let saved = store.agents().unwrap()[1].clone(); + assert_eq!(saved.imported[CLEANED], true); + let mut update = edit(); + update.system_prompt = format!("mine{D}also mine"); + store.save(&saved.id, saved.revision, update).unwrap(); + drop(store); + let (after, _) = cleaned(dir.path()); + assert_eq!(after[0].system_prompt, kept); + assert_eq!(after[1].system_prompt, format!("mine{D}also mine")); +} +#[test] +fn failed_cleanup_write_warns_and_sets_no_mark() { + let dir = tempfile::tempdir().unwrap(); + let baked = format!("role{D}team"); + let mut store = Store::open(dir.path().to_owned()).unwrap(); + store + .insert(vec![beta_agent( + "01", + &baked, + serde_json::json!({"global": {}}), + )]) .unwrap(); - assert_eq!(again[0].system_prompt, "role"); - assert_eq!(again[0].revision, after[0].revision); + drop(store); + fs::remove_file(dir.path().join("agents.previous.json")).ok(); + fs::create_dir(dir.path().join("agents.previous.json")).unwrap(); + let (agents, warning) = cleaned(dir.path()); + assert!(warning + .unwrap() + .contains("Could not remove old Buzz team text")); + assert_eq!(agents[0].system_prompt, baked); + assert!(agents[0].imported.get(CLEANED).is_none()); + fs::remove_dir(dir.path().join("agents.previous.json")).unwrap(); + let (agents, warning) = cleaned(dir.path()); + assert_eq!((warning, agents[0].system_prompt.as_str()), (None, "role")); } diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index 7971bfec6..a19011568 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -480,7 +480,8 @@ impl Host { .ok_or("Invalid local agent storage")? .to_path_buf(); let mut store = Store::open(root)?; - let inventory_warnings = store.migrate_legacy(&legacy_parent); + let mut inventory_warnings = store.migrate_legacy(&legacy_parent); + inventory_warnings.extend(store.clean_imported_prompts()?); let queued = store .snapshot()? .agents diff --git a/src-tauri/src/agents/tests.rs b/src-tauri/src/agents/tests.rs index 70bee861a..6454dc7e3 100644 --- a/src-tauri/src/agents/tests.rs +++ b/src-tauri/src/agents/tests.rs @@ -3174,3 +3174,29 @@ fn kept_agents_of_another_or_missing_owner_never_reach_their_credentials() { assert!(!missing.contains("different Buzz identity"), "{missing}"); assert_ne!(missing, IMPORT_GATE); } + +#[test] +fn failed_imported_prompt_cleanup_still_opens_the_controller_with_a_warning() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("store"); + std::fs::create_dir_all(&root).unwrap(); + seed(dir.path()); + let path = root.join("agents.json"); + let mut doc: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + doc["agents"][0]["systemPrompt"] = "role\n\n---\n# Team Instructions\nold".into(); + doc["agents"][0]["imported"] = json!({"global": {}}); + std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap(); + // A directory here makes the cleanup's backup write fail. + std::fs::create_dir(root.join("agents.previous.json")).unwrap(); + let host = AgentHost::open(Ok(( + root, + dir.path().join("legacy"), + dir.path().join("workspace"), + ))); + let value = serde_json::to_value(host.with(|host| host.snapshot()).unwrap()).unwrap(); + assert_eq!(value["agents"].as_array().unwrap().len(), 1); + assert!(value["inventoryWarnings"][0] + .as_str() + .unwrap() + .contains("Could not remove old Buzz team text")); +} From 2b3317bac880cccfd0825059434612bf5b048b3a Mon Sep 17 00:00:00 2001 From: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Date: Thu, 8 Oct 2026 19:17:59 -0400 Subject: [PATCH 05/43] test(agents): reopen a real beta import and always report cleanup write errors A write error after the new file landed (such as a failed directory sync) was hidden by the matching re-read. The re-read now only checks the file is still readable, and the error, which names which case it was, is always shown. Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- crates/agent-controller/src/import/tests.rs | 12 ++++++--- crates/agent-controller/src/store.rs | 11 ++++----- crates/agent-controller/src/store/tests.rs | 27 ++++++++------------- src-tauri/src/agents/tests.rs | 2 +- 4 files changed, 25 insertions(+), 27 deletions(-) diff --git a/crates/agent-controller/src/import/tests.rs b/crates/agent-controller/src/import/tests.rs index e1ff41fcd..9463eed1d 100644 --- a/crates/agent-controller/src/import/tests.rs +++ b/crates/agent-controller/src/import/tests.rs @@ -1126,7 +1126,8 @@ fn import_strips_baked_team_suffix_from_the_copy_and_flags_it_in_preview() { .join(LegacySource::Installed.app_directory()) .join("agents/managed-agents.json"); let mut records: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); - records[0]["system_prompt"] = json!("role\n\n---\n# Team Instructions\nbaked team"); + let kept = "role\n\n---\n# Team Instructions\nkept"; + records[0]["system_prompt"] = json!(format!("{kept}\n\n---\n# Team Instructions\nbaked team")); fs::write(&path, serde_json::to_vec(&records).unwrap()).unwrap(); let before = fs::read(&path).unwrap(); let mut imports = Imports::default(); @@ -1136,7 +1137,7 @@ fn import_strips_baked_team_suffix_from_the_copy_and_flags_it_in_preview() { .unwrap() .contains("baked team")); let clone = Imports::clone_settings(LegacySource::Installed, old.path().into(), PUB).unwrap(); - assert_eq!(clone.system_prompt, "role"); + assert_eq!(clone.system_prompt, kept); let mut store = Store::open(dest.path().into()).unwrap(); imports .commit( @@ -1146,8 +1147,13 @@ fn import_strips_baked_team_suffix_from_the_copy_and_flags_it_in_preview() { &Memory::default(), ) .unwrap(); - assert_eq!(store.agents().unwrap()[0].system_prompt, "role"); + assert_eq!(store.agents().unwrap()[0].system_prompt, kept); assert_eq!(fs::read(&path).unwrap(), before); + // Reopening never cuts the fresh import a second time. + drop(store); + let mut store = Store::open(dest.path().into()).unwrap(); + assert_eq!(store.clean_imported_prompts().unwrap(), None); + assert_eq!(store.agents().unwrap()[0].system_prompt, kept); } #[test] fn preview_does_not_flag_a_prompt_without_the_baked_suffix() { diff --git a/crates/agent-controller/src/store.rs b/crates/agent-controller/src/store.rs index 6f4aed2f3..afafb73ca 100644 --- a/crates/agent-controller/src/store.rs +++ b/crates/agent-controller/src/store.rs @@ -174,13 +174,12 @@ impl Store { return Ok(None); }; eprintln!("buzz: could not clean imported agent prompts: {error}"); - // The replace may have landed before the error; trust the saved file. - if serde_json::to_value(self.read()?.agents).ok() == serde_json::to_value(&doc.agents).ok() - { - return Ok(None); - } + // The new file may have landed before a later step such as the + // directory sync failed. Either way the saved file must still read + // back cleanly, and the error (which says which case) is shown. + self.read()?; Ok(Some(format!( - "Could not remove old Buzz team text from imported agent prompts: {error}" + "Could not finish removing old Buzz team text from imported agent prompts: {error}" ))) } pub(crate) fn reserve_import(&self) -> Result { diff --git a/crates/agent-controller/src/store/tests.rs b/crates/agent-controller/src/store/tests.rs index 90d3a2d76..044848519 100644 --- a/crates/agent-controller/src/store/tests.rs +++ b/crates/agent-controller/src/store/tests.rs @@ -874,34 +874,27 @@ fn cleanup_cuts_beta_imports_once_at_the_last_delimiter() { assert_eq!(again[0].revision, after[0].revision); } #[test] -fn cleanup_leaves_fresh_imports_and_later_owner_edits_alone() { +fn cleanup_leaves_later_owner_edits_alone() { let dir = tempfile::tempdir().unwrap(); - let kept = format!("role{D}kept"); let mut store = Store::open(dir.path().to_owned()).unwrap(); store - .insert(vec![ - // A fresh import is cut at import time and marked cleaned. - beta_agent( - "01", - &kept, - serde_json::json!({"global": {}, CLEANED: true}), - ), - beta_agent("02", "role", serde_json::json!({"global": {}})), - ]) + .insert(vec![beta_agent( + "01", + "role", + serde_json::json!({"global": {}}), + )]) .unwrap(); drop(store); - let (first, _) = cleaned(dir.path()); - assert_eq!(first[0].system_prompt, kept); + cleaned(dir.path()); let mut store = Store::open(dir.path().to_owned()).unwrap(); - let saved = store.agents().unwrap()[1].clone(); + let saved = store.agents().unwrap()[0].clone(); assert_eq!(saved.imported[CLEANED], true); let mut update = edit(); update.system_prompt = format!("mine{D}also mine"); store.save(&saved.id, saved.revision, update).unwrap(); drop(store); let (after, _) = cleaned(dir.path()); - assert_eq!(after[0].system_prompt, kept); - assert_eq!(after[1].system_prompt, format!("mine{D}also mine")); + assert_eq!(after[0].system_prompt, format!("mine{D}also mine")); } #[test] fn failed_cleanup_write_warns_and_sets_no_mark() { @@ -921,7 +914,7 @@ fn failed_cleanup_write_warns_and_sets_no_mark() { let (agents, warning) = cleaned(dir.path()); assert!(warning .unwrap() - .contains("Could not remove old Buzz team text")); + .contains("Could not finish removing old Buzz team text")); assert_eq!(agents[0].system_prompt, baked); assert!(agents[0].imported.get(CLEANED).is_none()); fs::remove_dir(dir.path().join("agents.previous.json")).unwrap(); diff --git a/src-tauri/src/agents/tests.rs b/src-tauri/src/agents/tests.rs index 6454dc7e3..46e0b5e5a 100644 --- a/src-tauri/src/agents/tests.rs +++ b/src-tauri/src/agents/tests.rs @@ -3198,5 +3198,5 @@ fn failed_imported_prompt_cleanup_still_opens_the_controller_with_a_warning() { assert!(value["inventoryWarnings"][0] .as_str() .unwrap() - .contains("Could not remove old Buzz team text")); + .contains("Could not finish removing old Buzz team text")); } From 9d49cc205bea260b58ec0b03938e27c300829d82 Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 8 Oct 2026 18:37:24 -0400 Subject: [PATCH 06/43] feat(teams): add shared instructions to any saved team Ordinary teams gain team text by moving to the portable record format; the ordinary record is retired and the portable record wins for both the app catalog and the native roster. Save refuses giving an agent two different team texts, naming the other team; teams without text never conflict. Signed-off-by: Duncan --- src-tauri/src/relay/kit.rs | 38 +++- .../ChannelTemplatesDialog.teamText.test.tsx | 205 ++++++++++++++++++ .../ChannelTemplatesDialog.tsx | 19 +- src/features/agents/team-instructions.test.ts | 31 +++ src/features/agents/team-instructions.ts | 52 +++++ .../channel-templates/capability.test.ts | 43 ++++ src/features/channel-templates/capability.ts | 31 ++- 7 files changed, 409 insertions(+), 10 deletions(-) create mode 100644 src/bundled/channel-templates/ChannelTemplatesDialog.teamText.test.tsx create mode 100644 src/features/agents/team-instructions.test.ts create mode 100644 src/features/agents/team-instructions.ts diff --git a/src-tauri/src/relay/kit.rs b/src-tauri/src/relay/kit.rs index a97fc875c..2b9ceb066 100644 --- a/src-tauri/src/relay/kit.rs +++ b/src-tauri/src/relay/kit.rs @@ -377,7 +377,7 @@ async fn decode_team_members( if events.len() >= 500 { return Err("Team catalog reached its read limit".into()); } - let mut heads: std::collections::BTreeMap)> = + let mut heads: std::collections::BTreeMap)> = std::collections::BTreeMap::new(); for event in events { let signed: nostr::event::Event = @@ -426,10 +426,14 @@ async fn decode_team_members( serde_json::from_value(raw["value"]["agents"].clone()) .map_err(|_| "Invalid team catalog")? }; - if heads.get(&id).map_or(true, |(time, old, _)| { - timestamp > *time || (timestamp == *time && event_id < *old) + // A portable record supersedes the ordinary record it replaced, even + // though retiring the ordinary record is the later write. + let portable = coordinate.starts_with(&format!("{MANIFEST_TAG}:")); + if heads.get(&id).map_or(true, |(old_portable, time, old, _)| { + (portable, timestamp) > (*old_portable, *time) + || (portable == *old_portable && timestamp == *time && event_id < *old) }) { - heads.insert(id, (timestamp, event_id, members)); + heads.insert(id, (portable, timestamp, event_id, members)); } } if host.viewer().await? != owner { @@ -437,7 +441,7 @@ async fn decode_team_members( } Ok(heads .into_iter() - .map(|(id, (created_at, event_id, members))| { + .map(|(id, (_, created_at, event_id, members))| { ( id, buzz_agent_controller::TeamCatalogEntry { @@ -620,4 +624,28 @@ mod binding_catalog_tests { .is_err() ); } + #[tokio::test] + async fn portable_record_outranks_the_later_retired_ordinary_record() { + let host = IdentityHost::fixture(); + let owner = host.viewer().await.unwrap(); + let community = "https://relay.test"; + let member = "ab".repeat(32); + let ordinary = serde_json::json!({"version":1,"community":community,"deleted":false, + "value":{"type":"team","id":"team-a","name":"Team","agents":[member]}}); + let portable = serde_json::json!({"version":2,"community":community,"deleted":false, + "value":{"type":"team","id":"team-a","name":"Team","agents":[member], + "portable":{"version":1,"owner":owner,"revision":"11111111-1111-4111-8111-111111111111", + "digest":"cd".repeat(32),"bytes":4,"chunks":1}}}); + let mut retired = ordinary.clone(); + retired["deleted"] = serde_json::json!(true); + let events = vec![ + signed(&host, ordinary, 1).await, + signed(&host, portable, 2).await, + signed(&host, retired, 3).await, + ]; + let teams = decode_team_members(&host, community, &owner, events) + .await + .unwrap(); + assert_eq!(teams["team-a"].members, vec![member]); + } } diff --git a/src/bundled/channel-templates/ChannelTemplatesDialog.teamText.test.tsx b/src/bundled/channel-templates/ChannelTemplatesDialog.teamText.test.tsx new file mode 100644 index 000000000..0e939ff35 --- /dev/null +++ b/src/bundled/channel-templates/ChannelTemplatesDialog.teamText.test.tsx @@ -0,0 +1,205 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import type { AgentControl } from "../../features/agents/control"; +import type { TeamSnapshot } from "../../features/agents/team-bundles"; +import type { RelaySession } from "../../features/relay/session"; +import type { ChannelKit } from "../../features/channel-templates/capability"; +import type { KitEntry, Team } from "../../features/channel-templates/model"; +import { ToastProvider } from "../../shared/design-system/ui/Toast"; +import { ChannelTemplatesDialog } from "./ChannelTemplatesDialog"; + +afterEach(cleanup); +const viewer = "c".repeat(64); +const community = "https://relay.example.test"; +const member = "a".repeat(64); +const session = { viewer, scope: `${community}:${viewer}` } as RelaySession; +const memberSnapshot = { + format: "buzz-agent-snapshot", + version: 1, + definition: { name: "Member", systemPrompt: "INDIVIDUAL" }, + profile: { displayName: "Member" }, + memory: { level: "none", entries: [] }, +} as unknown as TeamSnapshot["members"][number]; +const manifest = () => ({ + version: 1 as const, + owner: viewer, + revision: crypto.randomUUID(), + digest: "d".repeat(64), + bytes: 100, + chunks: 1, +}); +const ordinary: Team = { + type: "team", + id: "ordinary", + name: "Ordinary", + agents: [member], +}; +const portable: Team = { + type: "team", + id: "portable", + name: "Portable", + agents: [member], + portable: manifest(), +}; +const entryOf = (team: Team): KitEntry => ({ + eventId: `${team.id}-head`, + createdAt: 1, + record: { + version: team.portable ? 2 : 1, + community, + deleted: false, + value: team, + }, +}); +function setup( + initial: Team, + entries: KitEntry[], + texts: Record, +) { + const snapshotOf = (team: Team): TeamSnapshot => ({ + format: "buzz-team-snapshot", + version: 1, + team: { name: team.name, instructions: texts[team.id] ?? "" }, + members: team.agents.map(() => structuredClone(memberSnapshot)), + }); + const kitState = { status: "ready" as const, entries }; + const controlState = { + data: { + agents: [{ pubkey: member, relayUrl: "wss://relay.example.test" }], + }, + }; + const save = vi.fn().mockResolvedValue("saved"); + const savePortable = vi + .fn() + .mockResolvedValue("saved"); + const kit = { + available: true, + snapshot: () => kitState, + subscribe: () => () => {}, + ensure: vi.fn(), + refresh: vi.fn(), + save, + savePortable, + loadTeam: vi.fn(async (team: Team) => team.id), + } as unknown as ChannelKit; + const captureTeam = vi.fn(async (_team: unknown, members: string[]) => ({ + format: "buzz-team-snapshot", + version: 1, + team: { name: initial.name }, + members: members.map(() => structuredClone(memberSnapshot)), + })); + const control = { + previewTeam: vi.fn(async (id: string) => { + const team = entries.find( + (entry) => entry.record.value.id === JSON.parse(id), + )?.record.value as Team; + return snapshotOf(team); + }), + captureTeam, + snapshot: () => controlState, + } as unknown as AgentControl; + render( + true} + session={session} + control={control} + agents={[{ pubkey: member, name: "Member", avatar: undefined }]} + />, + { wrapper: ToastProvider }, + ); + return { save, savePortable, captureTeam }; +} +const instructions = () => + screen.findByRole("textbox", { name: "Team Instructions" }); +const saveTeam = () => + userEvent.click(screen.getByRole("button", { name: "Save team" })); + +it("adds team text to an existing ordinary team by converting it", async () => { + const { save, savePortable, captureTeam } = setup( + ordinary, + [entryOf(ordinary)], + {}, + ); + const field = await instructions(); + expect(field).toHaveValue(""); + await userEvent.type(field, "SHARED"); + await saveTeam(); + await waitFor(() => expect(savePortable).toHaveBeenCalledOnce()); + expect(captureTeam).toHaveBeenCalledOnce(); + expect(savePortable.mock.calls[0]?.[1].team.instructions).toBe("SHARED"); + expect(savePortable.mock.calls[0]?.[2]).toBe("ordinary-head"); + expect(save).not.toHaveBeenCalled(); +}); + +it("keeps an ordinary team ordinary when no text is added", async () => { + const { save, savePortable } = setup(ordinary, [entryOf(ordinary)], {}); + await instructions(); + await userEvent.type(screen.getByRole("textbox", { name: "Name" }), "!"); + await saveTeam(); + await waitFor(() => expect(save).toHaveBeenCalledOnce()); + expect(savePortable).not.toHaveBeenCalled(); +}); + +it("shows the saved text and keeps it on a name-only edit", async () => { + const { savePortable } = setup(portable, [entryOf(portable)], { + portable: "SAVED", + }); + const field = await instructions(); + await waitFor(() => expect(field).toHaveValue("SAVED")); + await userEvent.type(screen.getByRole("textbox", { name: "Name" }), "!"); + await saveTeam(); + await waitFor(() => expect(savePortable).toHaveBeenCalledOnce()); + const [team, snapshot] = savePortable.mock.calls[0] ?? []; + expect(team?.agents).toEqual([member]); + expect(snapshot?.team.instructions).toBe("SAVED"); + expect(snapshot?.members).toHaveLength(1); +}); + +it("saves an explicit clear as empty text", async () => { + const { savePortable } = setup(portable, [entryOf(portable)], { + portable: "SAVED", + }); + const field = await instructions(); + await waitFor(() => expect(field).toHaveValue("SAVED")); + await userEvent.clear(field); + await saveTeam(); + await waitFor(() => expect(savePortable).toHaveBeenCalledOnce()); + expect(savePortable.mock.calls[0]?.[1].team.instructions).toBe(""); +}); + +it("refuses a save that gives a member two different team texts", async () => { + const other = { ...portable, id: "other", name: "Reviewers" }; + const { save, savePortable } = setup( + ordinary, + [entryOf(ordinary), entryOf(other)], + { other: "THEIRS" }, + ); + await userEvent.type(await instructions(), "OURS"); + await saveTeam(); + expect(await screen.findByRole("alert")).toHaveTextContent('"Reviewers"'); + expect(savePortable).not.toHaveBeenCalled(); + expect(save).not.toHaveBeenCalled(); +}); + +it.each([ + ["has no text", ""], + ["has the same text", "OURS"], +])("allows sharing a member with a team that %s", async (_, text) => { + const other = { ...portable, id: "other", name: "Reviewers" }; + const { savePortable } = setup( + ordinary, + [entryOf(ordinary), entryOf(other)], + { other: text }, + ); + await userEvent.type(await instructions(), "OURS"); + await saveTeam(); + await waitFor(() => expect(savePortable).toHaveBeenCalledOnce()); +}); diff --git a/src/bundled/channel-templates/ChannelTemplatesDialog.tsx b/src/bundled/channel-templates/ChannelTemplatesDialog.tsx index 1dd7da998..c1896539b 100644 --- a/src/bundled/channel-templates/ChannelTemplatesDialog.tsx +++ b/src/bundled/channel-templates/ChannelTemplatesDialog.tsx @@ -1,6 +1,10 @@ import { sameCommunityAgents } from "../../features/agents/choices"; import type { AgentControl } from "../../features/agents/control"; import type { TeamSnapshot } from "../../features/agents/team-bundles"; +import { + readTeamTexts, + teamTextConflict, +} from "../../features/agents/team-instructions"; import { relayOrigin } from "../../features/communities/destination"; import { Textarea } from "../../shared/design-system/ui/Textarea"; import type { RelaySession } from "../../features/relay/session"; @@ -152,7 +156,6 @@ export function ChannelTemplatesDialog({ ); const canCapture = draft.type === "team" && - !expected && !!control?.captureTeam && draft.agents.length > 0 && draft.agents.every((key) => @@ -168,10 +171,20 @@ export function ChannelTemplatesDialog({ try { if ( draft.type === "team" && - ((initial.type === "team" && initial.portable) || canCapture) + ((initial.type === "team" && initial.portable) || + (canCapture && hasPortableFields)) ) { if (!session?.viewer) throw new Error("Choose a community before saving a team"); + const text = portable?.team.instructions ?? ""; + if (text.trim() && control) { + const conflict = teamTextConflict( + await readTeamTexts(kit, control), + draft, + text, + ); + if (conflict) throw new Error(conflict); + } const community = relayOrigin( session.scope.slice(0, -(session.viewer.length + 1)), ); @@ -454,7 +467,7 @@ export function ChannelTemplatesDialog({