Files
buzz/desktop/src-tauri/src/migration/backfill_tests.rs
Will Pfleger aee6314484 fix(desktop): strip legacy baked team instructions from stored prompts (#3035)
Agents in a team receive two `Team Instructions` blocks per turn, and
the observer feed renders two Team Instructions cards for them.

There are two producers. `with_team()` in `crates/buzz-acp/src/pool.rs`
appends the LIVE `[Team Instructions]` block from the runtime
`TeamRecord` — that one is correct. The second is baked into the stored
`system_prompt` itself: records written before the runtime framing
landed were composed by the now-removed `compose_prompt()` in
`buzz-persona`, which appended `"\n\n---\n# Team Instructions\n"` plus a
frozen copy of the team instructions. So an affected agent is fed a
stale roster ahead of the current one, and the transcript parser —
correctly — reports both.

Fixing this in `parseSystemPromptSections` would hide the symptom while
the agent kept receiving the stale bytes, so the suffix is removed at
rest by a boot migration.

`strip_baked_team_instructions` splits each stored `system_prompt` at
the LAST occurrence of the exact delimiter and keeps the text before it.
Last-occurrence matches the parser's own `lastIndexOf` guard: a persona
body may quote a delimiter-shaped passage, and only the final one is the
producer boundary. The match is byte-exact — a bare `---`, a `# Team
Instructions` heading at a different position, or a single preceding
newline are author content and are left alone. It applies to every
record regardless of `team_id` / `persona_id` / `pubkey`: the key-less
definition records carry the suffix exactly as the instances minted from
them do. A prompt that was nothing but the suffix becomes `None`, not
`Some("")`, matching the absent-prompt convention in
`AgentDefinition::into_agent_record`.

Stripping a definition's prompt changes its `persona_content_hash`,
which is the drift basis behind the Agents-menu "out of date" badge.
Left alone, every linked instance would light up stale for a change the
user never made. The migration therefore advances the pin of instances
whose `persona_source_version` still equals the definition's PRE-strip
hash — the same conditional `refresh_builtin_agent_avatars` already
uses. An instance that had genuinely drifted keeps its stale pin, and
its badge.

The migration runs after `fold_personas_into_agent_store` so definitions
lifted out of the legacy `personas.json` are cleaned in the same boot,
and before `backfill_standalone_agents` so a manufactured definition
never snapshots a suffix about to be removed. It writes only when at
least one record changed, so a second boot is a true no-op, and takes a
create-if-absent backup at
`managed-agents.json.pre-team-suffix-strip.bak` following the
`pre-backfill.bak` contract — a re-run after a partial failure cannot
replace the pristine backup with a half-migrated snapshot. An
unparseable store errors without writing and without taking a backup,
leaving the file for manual recovery.

Pass 5's legacy branch in `agentSessionTranscriptHelpers.ts` is
deliberately untouched: un-migrated installs and snapshot imports still
need it.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-07-26 19:33:43 -04:00

333 lines
12 KiB
Rust

use super::backfill_standalone_agents_in_dir;
use crate::managed_agents::spawn_hash::spawn_config_hash;
use crate::managed_agents::{AgentDefinition, ManagedAgentRecord};
use crate::migration::test_support::{read_agents_json, write_agents_json};
use std::path::Path;
fn standalone_agent_json(name: &str, pubkey: &str, prompt: Option<&str>) -> serde_json::Value {
serde_json::json!({
"name": name,
"pubkey": pubkey,
"relay_url": "ws://localhost:3000",
"acp_command": "buzz-acp",
"agent_command": "goose",
"agent_args": [],
"mcp_command": "",
"turn_timeout_seconds": 320,
"parallelism": 4,
"system_prompt": prompt,
"model": "gpt-x",
"provider": "openai",
"respond_to": "anyone",
"env_vars": { "API_KEY": "secret" },
"start_on_app_launch": true,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"last_started_at": null,
"last_stopped_at": null,
"last_exit_code": null,
"last_error": null
})
}
fn load_typed(dir: &Path) -> Vec<ManagedAgentRecord> {
let content = std::fs::read_to_string(dir.join("agents").join("managed-agents.json")).unwrap();
serde_json::from_str(&content).unwrap()
}
fn base(dir: &Path) -> std::path::PathBuf {
dir.join("agents")
}
#[test]
fn backfill_links_standalone_agent_to_manufactured_definition() {
let dir = tempfile::tempdir().unwrap();
let pubkey = "a".repeat(64);
write_agents_json(
dir.path(),
&serde_json::json!([standalone_agent_json(
"Solo",
&pubkey,
Some("You are Solo.")
)]),
);
let backfilled = backfill_standalone_agents_in_dir(&base(dir.path())).unwrap();
assert_eq!(backfilled, 1);
let records = load_typed(dir.path());
assert_eq!(records.len(), 2, "instance + manufactured definition");
let instance = records.iter().find(|r| !r.pubkey.is_empty()).unwrap();
assert_eq!(instance.persona_id.as_deref(), Some(pubkey.as_str()));
assert!(instance.persona_source_version.is_some());
let definition = records.iter().find(|r| r.pubkey.is_empty()).unwrap();
assert_eq!(definition.slug.as_deref(), Some(pubkey.as_str()));
assert_eq!(definition.system_prompt.as_deref(), Some("You are Solo."));
assert_eq!(definition.model.as_deref(), Some("gpt-x"));
// Env COPIED (B5 pin): later instances inherit a working config.
assert_eq!(
definition.env_vars.get("API_KEY").map(String::as_str),
Some("secret")
);
// Instance quad copied up as the definition's defaults.
assert_eq!(definition.definition_respond_to.as_deref(), Some("anyone"));
assert_eq!(definition.definition_parallelism, Some(4));
// The recorded version matches the definition's actual content hash —
// the drift badge starts clean.
let view = definition.to_definition_view().unwrap();
let expected = crate::managed_agents::persona_events::persona_content_hash(
&crate::managed_agents::persona_events::persona_event_content(&view),
);
assert_eq!(
instance.persona_source_version.as_deref(),
Some(expected.as_str())
);
}
#[test]
fn backfilled_definition_carries_prompt_present_even_if_empty() {
// LOAD-BEARING (B5 gates): old readers hard-fail on an absent prompt. A
// prompt-less backfilled definition would leave a wiped old device with
// no heal source, permanently. `AgentDefinition.system_prompt` is a plain
// String and the outbound projection wraps it in `Some` unconditionally
// — this row pins that chain against refactors.
let dir = tempfile::tempdir().unwrap();
let pubkey = "b".repeat(64);
write_agents_json(
dir.path(),
&serde_json::json!([standalone_agent_json("NoPrompt", &pubkey, None)]),
);
backfill_standalone_agents_in_dir(&base(dir.path())).unwrap();
let records = load_typed(dir.path());
let definition = records.iter().find(|r| r.pubkey.is_empty()).unwrap();
let view: AgentDefinition = definition.to_definition_view().unwrap();
assert_eq!(view.system_prompt, "", "empty, not absent");
let content = crate::managed_agents::persona_events::persona_event_content(&view);
assert_eq!(
content.system_prompt.as_deref(),
Some(""),
"wire projection must carry Some(\"\") — the old-reader heal source"
);
}
#[test]
fn backfill_of_promptless_record_keeps_spawn_hash_stable() {
// B5 hash row 2: pre-backfill the record hashes prompt None; post-backfill
// the prospective re-snapshot pulls Some("") from the manufactured
// definition. The spawn layer treats an empty prompt as no prompt (env
// absent either way), so the hash must not move — otherwise every
// prompt-less standalone agent lights the restart badge on upgrade.
let dir = tempfile::tempdir().unwrap();
let pubkey = "c".repeat(64);
write_agents_json(
dir.path(),
&serde_json::json!([standalone_agent_json("NoPrompt", &pubkey, None)]),
);
let pre_records = load_typed(dir.path());
let pre_instance = pre_records.iter().find(|r| !r.pubkey.is_empty()).unwrap();
let hash_before = spawn_config_hash(
pre_instance,
&[],
&[],
"wss://ws.example",
&Default::default(),
);
backfill_standalone_agents_in_dir(&base(dir.path())).unwrap();
let post_records = load_typed(dir.path());
let post_instance = post_records.iter().find(|r| !r.pubkey.is_empty()).unwrap();
let personas: Vec<AgentDefinition> = post_records
.iter()
.filter_map(|r| r.to_definition_view())
.collect();
let hash_after = spawn_config_hash(
post_instance,
&personas,
&[],
"wss://ws.example",
&Default::default(),
);
assert_eq!(
hash_before, hash_after,
"backfill must not flip the restart badge for prompt-less agents"
);
}
#[test]
fn backfill_of_prompted_record_keeps_spawn_hash_stable() {
// The general no-behavior-change rail: a standalone agent WITH config
// must also hash identically across backfill (the definition snapshots
// the record's own values, so the re-snapshot writes back what is
// already there).
let dir = tempfile::tempdir().unwrap();
let pubkey = "d".repeat(64);
write_agents_json(
dir.path(),
&serde_json::json!([standalone_agent_json(
"Solo",
&pubkey,
Some("You are Solo.")
)]),
);
let pre_records = load_typed(dir.path());
let pre_instance = pre_records.iter().find(|r| !r.pubkey.is_empty()).unwrap();
let hash_before = spawn_config_hash(
pre_instance,
&[],
&[],
"wss://ws.example",
&Default::default(),
);
backfill_standalone_agents_in_dir(&base(dir.path())).unwrap();
let post_records = load_typed(dir.path());
let post_instance = post_records.iter().find(|r| !r.pubkey.is_empty()).unwrap();
let personas: Vec<AgentDefinition> = post_records
.iter()
.filter_map(|r| r.to_definition_view())
.collect();
let hash_after = spawn_config_hash(
post_instance,
&personas,
&[],
"wss://ws.example",
&Default::default(),
);
assert_eq!(hash_before, hash_after);
}
#[test]
fn second_run_is_a_no_op_and_preserves_pristine_backup() {
// B5 gates: double-run idempotence + create-if-absent .bak. Run 1
// migrates; run 2 must change nothing and must NOT clobber the pristine
// pre-migration backup with a half-migrated snapshot.
let dir = tempfile::tempdir().unwrap();
let pubkey = "e".repeat(64);
write_agents_json(
dir.path(),
&serde_json::json!([standalone_agent_json("Solo", &pubkey, Some("P"))]),
);
let pristine = std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap();
assert_eq!(
backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(),
1
);
let after_first =
std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap();
assert_eq!(
backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(),
0,
"second run is a no-op"
);
let after_second =
std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap();
assert_eq!(after_first, after_second, "store untouched by re-run");
let bak =
std::fs::read_to_string(base(dir.path()).join("managed-agents.json.pre-backfill.bak"))
.unwrap();
assert_eq!(
bak, pristine,
"backup is the PRE-migration state, never clobbered"
);
}
#[test]
fn definitions_and_linked_records_are_untouched() {
// Already-linked instances and existing definitions pass through
// byte-identical; a store with nothing to backfill takes no backup.
let dir = tempfile::tempdir().unwrap();
let pubkey = "f".repeat(64);
let mut linked = standalone_agent_json("Linked", &pubkey, Some("P"));
linked["persona_id"] = serde_json::json!("some-definition");
write_agents_json(dir.path(), &serde_json::json!([linked]));
assert_eq!(
backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(),
0
);
assert!(
!base(dir.path())
.join("managed-agents.json.pre-backfill.bak")
.exists(),
"no work, no backup"
);
let records = read_agents_json(dir.path());
assert_eq!(records.len(), 1, "nothing manufactured");
}
#[test]
fn slug_collision_fails_loudly_per_record_and_continues() {
// A pre-existing definition improbably slugged as an agent's pubkey:
// that record is skipped (logged), the rest proceed.
let dir = tempfile::tempdir().unwrap();
let colliding = "1".repeat(64);
let clean = "2".repeat(64);
let mut definition = standalone_agent_json("Def", "", Some("P"));
definition["slug"] = serde_json::json!(colliding.clone());
definition["pubkey"] = serde_json::json!("");
write_agents_json(
dir.path(),
&serde_json::json!([
definition,
standalone_agent_json("Collides", &colliding, Some("P")),
standalone_agent_json("Clean", &clean, Some("P")),
]),
);
assert_eq!(
backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(),
1,
"collision skipped, clean record backfilled"
);
let records = load_typed(dir.path());
let collided = records.iter().find(|r| r.pubkey == colliding).unwrap();
assert_eq!(collided.persona_id, None, "collided record left standalone");
let clean_rec = records.iter().find(|r| r.pubkey == clean).unwrap();
assert_eq!(clean_rec.persona_id.as_deref(), Some(clean.as_str()));
}
#[cfg(unix)]
#[test]
fn backfill_creates_the_backup_owner_only() {
// Same contract as the live store writer: this backup is a verbatim copy of
// a file that carries plaintext agent nsecs when the keyring is unreachable,
// so it is owner-only from the initial open rather than via a post-write
// chmod that would leave a umask window.
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let pubkey = "e".repeat(64);
let mut record = standalone_agent_json("Solo", &pubkey, Some("P"));
record["private_key_nsec"] = serde_json::json!("nsec1exampleplaintextkey");
write_agents_json(dir.path(), &serde_json::json!([record]));
assert_eq!(
backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(),
1
);
let bak = base(dir.path()).join("managed-agents.json.pre-backfill.bak");
let mode = std::fs::metadata(&bak).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "backup of an inline nsec must be owner-only");
assert!(
std::fs::read_to_string(&bak)
.unwrap()
.contains("nsec1exampleplaintextkey"),
"the fixture really did carry an inline key"
);
}