Skip to main content

agentmux_srv/backend/
agent_seed.rs

1// Copyright 2025, Command Line Inc.
2// Copyright 2025-2026, AgentMux Corp.
3// SPDX-License-Identifier: Apache-2.0
4
5//! Agent seed engine: preloads agents from an embedded manifest on first launch.
6//! Seeds agents with identity + content. Provider, agent_type, and environment
7//! are NOT baked into the manifest — they default to sensible values and are
8//! user-configurable via the Agent settings UI after seeding.
9
10use std::sync::Arc;
11
12use serde::Deserialize;
13
14use super::storage::bundles::Bundle;
15use super::storage::store::{AgentDefinition, AgentContent, AgentSkill, Store};
16use super::storage::StoreError;
17
18/// Report returned after seeding.
19pub struct SeedReport {
20    pub created: usize,
21    pub skipped: usize,
22}
23
24/// Top-level seed manifest structure.
25#[derive(Debug, Deserialize)]
26struct SeedManifest {
27    #[allow(dead_code)]
28    version: u32,
29    agents: Vec<SeedAgent>,
30    #[serde(default)]
31    memories: Vec<SeedMemory>,
32}
33
34/// A bundle in the seed manifest.
35#[derive(Debug, Deserialize)]
36struct SeedMemory {
37    id: String,
38    name: String,
39    #[serde(default)]
40    description: String,
41    /// When true this bundle is injected into every agent's CLAUDE.md at
42    /// launch (Memory → Global tier). When false it is available in the
43    /// Bundle manager but must be selected per-agent.
44    #[serde(default)]
45    is_global: bool,
46    #[serde(default)]
47    instructions: String,
48    /// SHA-256 (hex) of instructions this bundle shipped with before. A bundle
49    /// that still holds exactly one of them was never edited, so the seed
50    /// replaces it with `instructions`; a bundle a user changed is left alone.
51    #[serde(default)]
52    replaces_instructions_sha256: Vec<String>,
53}
54
55/// An agent definition in the seed manifest.
56#[derive(Debug, Deserialize)]
57struct SeedAgent {
58    id: String,
59    name: String,
60    #[serde(default = "default_icon")]
61    icon: String,
62    /// Defaults to "claude" when absent. User can change in Agent settings UI.
63    #[serde(default = "default_provider")]
64    provider: String,
65    /// Defaults to "host" when absent. User can change in Agent settings UI.
66    #[serde(default = "default_agent_type")]
67    agent_type: String,
68    /// Defaults to the current OS when absent.
69    #[serde(default = "default_environment")]
70    environment: String,
71    #[serde(default)]
72    description: String,
73    #[serde(default)]
74    working_directory: String,
75    #[serde(default)]
76    shell: String,
77    #[serde(default)]
78    agent_bus_id: String,
79    #[serde(default)]
80    auto_start: bool,
81    #[serde(default)]
82    restart_on_crash: bool,
83    /// Container image to pull and run when agent_type == "container".
84    /// Omit for host-only providers. Matches cli-catalog.ts `containerImage`.
85    #[serde(default)]
86    container_image: String,
87    #[serde(default)]
88    content: SeedContent,
89    #[serde(default)]
90    skills: Vec<SeedSkill>,
91}
92
93fn default_icon() -> String {
94    "\u{2726}".to_string()
95}
96
97fn default_provider() -> String {
98    "claude".to_string()
99}
100
101fn default_agent_type() -> String {
102    "host".to_string()
103}
104
105fn default_environment() -> String {
106    std::env::consts::OS.to_string()
107}
108
109/// Content blobs to seed for an agent.
110#[derive(Debug, Default, Deserialize)]
111struct SeedContent {
112    #[serde(default)]
113    agentmd: Option<String>,
114    #[serde(default)]
115    mcp: Option<String>,
116    #[serde(default)]
117    env: Option<String>,
118    #[serde(default)]
119    soul: Option<String>,
120    #[serde(default)]
121    startup: Option<String>,
122}
123
124/// A skill definition in the seed manifest.
125#[derive(Debug, Deserialize)]
126struct SeedSkill {
127    name: String,
128    #[serde(default)]
129    trigger: String,
130    #[serde(default = "default_skill_type")]
131    skill_type: String,
132    #[serde(default)]
133    description: String,
134    #[serde(default)]
135    content: String,
136}
137
138fn default_skill_type() -> String {
139    "prompt".to_string()
140}
141
142/// The embedded seed manifest JSON.
143const SEED_MANIFEST: &str = include_str!("../../agent-seed.json");
144
145/// Seed agent definitions from the embedded manifest.
146/// Skips agents whose ID already exists in the database.
147pub fn seed_agents(mstore: &Arc<Store>) -> Result<SeedReport, StoreError> {
148    let manifest: SeedManifest = serde_json::from_str(SEED_MANIFEST)
149        .map_err(|e| StoreError::Other(format!("agent seed: parse manifest: {e}")))?;
150
151    let existing = mstore.agent_def_list()?;
152    let existing_ids: std::collections::HashSet<String> =
153        existing.iter().map(|a| a.id.clone()).collect();
154
155    let now = agentmux_common::time::now_ms();
156
157    let mut created = 0usize;
158    let mut skipped = 0usize;
159
160    for agent_def in &manifest.agents {
161        if existing_ids.contains(&agent_def.id) {
162            skipped += 1;
163            continue;
164        }
165
166        // Insert agent. For seeded agents, the manifest `id` is already
167        // a human-readable slug-form string (agentx, agent1, etc.), so
168        // reuse it as the slug. agent_def_insert collision-resolves if
169        // needed and mutates the slug field in place.
170        let mut agent = AgentDefinition {
171            id: agent_def.id.clone(),
172            slug: agent_def.id.clone(),
173            name: agent_def.name.clone(),
174            icon: agent_def.icon.clone(),
175            provider: agent_def.provider.clone(),
176            description: agent_def.description.clone(),
177            working_directory: agent_def.working_directory.clone(),
178            shell: agent_def.shell.clone(),
179            provider_flags: String::new(),
180            auto_start: if agent_def.auto_start { 1 } else { 0 },
181            restart_on_crash: if agent_def.restart_on_crash { 1 } else { 0 },
182            idle_timeout_minutes: 0,
183            created_at: now,
184            agent_type: agent_def.agent_type.clone(),
185            environment: agent_def.environment.clone(),
186            agent_bus_id: agent_def.agent_bus_id.clone(),
187            is_seeded: 1,
188            accounts: String::new(),
189            parent_id: String::new(),
190            branch_label: String::new(),
191            updated_at: now,
192            // First seeding always lands templates visible. Phase 2
193            // user_hidden is set by `agent_def_set_hidden` after the
194            // user explicitly hides; new template ids in re-seed are
195            // force-reset to 0 below (see `reseed_if_needed`).
196            user_hidden: 0,
197            container_image: agent_def.container_image.clone(),
198            container_volumes: "[]".to_string(),
199            container_name: String::new(),
200            use_ambient_login: 0,
201            model_vendor_base_url: String::new(), // manifest doesn't declare this yet
202            auto_continue_enabled: 0,
203            memory_id: String::new(),
204            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
205        };
206        mstore.agent_def_insert(&mut agent)?;
207
208        // Insert content blobs
209        let content_pairs = [
210            ("agentmd", &agent_def.content.agentmd),
211            ("mcp", &agent_def.content.mcp),
212            ("env", &agent_def.content.env),
213            ("soul", &agent_def.content.soul),
214            ("startup", &agent_def.content.startup),
215        ];
216        for (content_type, maybe_content) in &content_pairs {
217            if let Some(content) = maybe_content {
218                if !content.is_empty() {
219                    mstore.agent_content_set(&AgentContent {
220                        agent_id: agent_def.id.clone(),
221                        content_type: content_type.to_string(),
222                        content: content.clone(),
223                        updated_at: now,
224                    })?;
225                }
226            }
227        }
228
229        // Insert skills
230        for skill_def in &agent_def.skills {
231            let skill = AgentSkill {
232                id: uuid::Uuid::new_v4().to_string(),
233                agent_id: agent_def.id.clone(),
234                name: skill_def.name.clone(),
235                trigger: skill_def.trigger.clone(),
236                skill_type: skill_def.skill_type.clone(),
237                description: skill_def.description.clone(),
238                content: skill_def.content.clone(),
239                created_at: now,
240            };
241            mstore.agent_skill_insert(&skill)?;
242        }
243
244        created += 1;
245    }
246
247    Ok(SeedReport { created, skipped })
248}
249
250/// Seed bundles from the manifest. Skips any bundle whose ID already
251/// exists — this is a one-time seed, not an upsert on every startup.
252fn seed_memories(mstore: &Arc<Store>, manifest: &SeedManifest) -> Result<usize, StoreError> {
253    let existing = mstore.bundle_list()?;
254    let existing_by_id: std::collections::HashMap<String, Bundle> =
255        existing.into_iter().map(|m| (m.id.clone(), m)).collect();
256
257    let now = agentmux_common::time::now_ms();
258
259    let mut created = 0usize;
260    for (idx, mem_def) in manifest.memories.iter().enumerate() {
261        if let Some(current) = existing_by_id.get(&mem_def.id) {
262            if is_untouched_earlier_seed(current, mem_def) {
263                let mut refreshed = current.clone();
264                refreshed.instructions = mem_def.instructions.clone();
265                refreshed.updated_at = now;
266                match mstore.bundle_upsert(&refreshed) {
267                    Ok(()) => created += 1,
268                    Err(e) => tracing::warn!(id = %mem_def.id, error = %e, "agent seed: refreshing an untouched bundle failed"),
269                }
270            }
271            continue;
272        }
273        let memory = Bundle {
274            id: mem_def.id.clone(),
275            name: mem_def.name.clone(),
276            description: mem_def.description.clone(),
277            is_blank: false,
278            is_global: mem_def.is_global,
279            provider: String::new(),
280            model: String::new(),
281            instructions: mem_def.instructions.clone(),
282            instructions_by_provider: "{}".to_string(),
283            context_files: "[]".to_string(),
284            mcp_servers: "[]".to_string(),
285            skills: "[]".to_string(),
286            // Seed initial global-bundle order by manifest position so seeded
287            // sections start in a deterministic order; users reorder later.
288            sort_order: idx as i64,
289            created_at: now,
290            updated_at: now,
291            is_system: false,
292        };
293        // Use warn-and-skip rather than ? so a user bundle whose name
294        // collides with the seeded name (UNIQUE constraint on name) does
295        // not abort the remainder of the seed loop.
296        match mstore.bundle_upsert(&memory) {
297            Ok(()) => { created += 1; }
298            Err(e) => {
299                tracing::warn!(
300                    id = %mem_def.id,
301                    name = %mem_def.name,
302                    error = %e,
303                    "agent seed: skipping bundle due to upsert error (name collision?)"
304                );
305            }
306        }
307    }
308
309    Ok(created)
310}
311
312/// Is `current` exactly an earlier text of this seeded bundle, i.e. never edited?
313fn is_untouched_earlier_seed(current: &Bundle, mem_def: &SeedMemory) -> bool {
314    use sha2::{Digest, Sha256};
315    if mem_def.replaces_instructions_sha256.is_empty() || current.instructions == mem_def.instructions {
316        return false;
317    }
318    let digest = hex::encode(Sha256::digest(current.instructions.as_bytes()));
319    mem_def.replaces_instructions_sha256.iter().any(|h| h.eq_ignore_ascii_case(&digest))
320}
321
322/// Run auto-seed on startup. Seeds if empty, or re-seeds if manifest version changed.
323/// Re-seeding updates existing seeded agents and removes seeded agents not in the manifest.
324pub fn auto_seed_on_startup(mstore: &Arc<Store>) {
325    let manifest: SeedManifest = match serde_json::from_str(SEED_MANIFEST) {
326        Ok(m) => m,
327        Err(e) => {
328            tracing::error!("agent seed: failed to parse seed manifest: {e}");
329            return;
330        }
331    };
332
333    match mstore.agent_def_count() {
334        Ok(0) => {
335            tracing::info!("agent seed: no agents found, seeding from manifest v{}...", manifest.version);
336            match seed_agents(mstore) {
337                Ok(report) => {
338                    tracing::info!(
339                        "agent seed: seeded {} agents ({} skipped)",
340                        report.created,
341                        report.skipped
342                    );
343                }
344                Err(e) => tracing::error!("agent seed: failed: {e}"),
345            }
346        }
347        Ok(count) => {
348            // Check if we need to re-seed (manifest version changed)
349            match reseed_if_needed(mstore, &manifest) {
350                Ok(Some(report)) => {
351                    tracing::info!(
352                        "agent seed: re-seeded from manifest v{}: {} created, {} updated, {} removed",
353                        manifest.version, report.created, report.updated, report.removed,
354                    );
355                }
356                Ok(None) => {
357                    tracing::info!("agent seed: {} agents exist, manifest up to date", count);
358                }
359                Err(e) => tracing::error!("agent seed: re-seed failed: {e}"),
360            }
361        }
362        Err(e) => tracing::error!("agent seed: failed to count agents: {e}"),
363    }
364
365    // Seed bundles once — an existing ID is skipped, unless it still holds an
366    // earlier seeded text exactly (`replaces_instructions_sha256`): that one
367    // is refreshed.
368    if !manifest.memories.is_empty() {
369        match seed_memories(mstore, &manifest) {
370            Ok(0) => {}
371            Ok(n) => tracing::info!("agent seed: seeded or refreshed {n} bundles"),
372            Err(e) => tracing::error!("agent seed: failed to seed memories: {e}"),
373        }
374    }
375}
376
377/// Report from a re-seed operation.
378pub struct ReseedReport {
379    pub created: usize,
380    pub updated: usize,
381    pub removed: usize,
382}
383
384/// Re-seed if the manifest version is newer than what's in the DB.
385/// Updates seeded agents, adds new ones, removes seeded agents not in the manifest.
386fn reseed_if_needed(
387    mstore: &Arc<Store>,
388    manifest: &SeedManifest,
389) -> Result<Option<ReseedReport>, StoreError> {
390    let existing = mstore.agent_def_list()?;
391
392    // Check if any seeded agent needs updating by comparing providers/descriptions
393    let manifest_ids: std::collections::HashSet<&str> =
394        manifest.agents.iter().map(|a| a.id.as_str()).collect();
395    let existing_map: std::collections::HashMap<&str, &AgentDefinition> =
396        existing.iter().map(|a| (a.id.as_str(), a)).collect();
397
398    let mut needs_reseed = false;
399
400    // Check for new agents or changed providers
401    for agent_def in &manifest.agents {
402        match existing_map.get(agent_def.id.as_str()) {
403            None => { needs_reseed = true; break; }
404            Some(existing_agent) => {
405                // Only compare identity fields, NOT provider/agent_type/environment
406                // which the user may have changed via the Agent settings UI.
407                if existing_agent.description != agent_def.description {
408                    needs_reseed = true;
409                    break;
410                }
411            }
412        }
413    }
414
415    // Check for agents to remove (seeded agents not in manifest)
416    for agent in &existing {
417        if agent.is_seeded == 1 && !manifest_ids.contains(agent.id.as_str()) {
418            needs_reseed = true;
419            break;
420        }
421    }
422
423    if !needs_reseed {
424        return Ok(None);
425    }
426
427    let now = agentmux_common::time::now_ms();
428
429    let mut created = 0usize;
430    let mut updated = 0usize;
431    let mut removed = 0usize;
432
433    // Upsert agents from manifest
434    for agent_def in &manifest.agents {
435        let mut agent = AgentDefinition {
436            id: agent_def.id.clone(),
437            slug: agent_def.id.clone(),
438            name: agent_def.name.clone(),
439            icon: agent_def.icon.clone(),
440            provider: agent_def.provider.clone(),
441            description: agent_def.description.clone(),
442            working_directory: agent_def.working_directory.clone(),
443            shell: agent_def.shell.clone(),
444            provider_flags: String::new(),
445            auto_start: if agent_def.auto_start { 1 } else { 0 },
446            restart_on_crash: if agent_def.restart_on_crash { 1 } else { 0 },
447            idle_timeout_minutes: 0,
448            created_at: now,
449            agent_type: agent_def.agent_type.clone(),
450            environment: agent_def.environment.clone(),
451            agent_bus_id: agent_def.agent_bus_id.clone(),
452            is_seeded: 1,
453            accounts: String::new(),
454            parent_id: String::new(),
455            branch_label: String::new(),
456            updated_at: now,
457            // Newly-added template ids start visible (overwritten below
458            // when the row already exists). Phase 2 of the two-tier
459            // picker spec (Q2 Decision Y) requires NEW template ids
460            // surface once even if a same-named template was previously
461            // hidden — the `else` branch below honours that by lining
462            // up against existing ids only.
463            user_hidden: 0,
464            container_image: agent_def.container_image.clone(),
465            container_volumes: "[]".to_string(),
466            container_name: String::new(),
467            use_ambient_login: 0,
468            model_vendor_base_url: String::new(), // manifest doesn't declare this yet
469            auto_continue_enabled: 0,
470            memory_id: String::new(),
471            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
472        };
473
474        if let Some(existing_agent) = existing_map.get(agent_def.id.as_str()) {
475            // Preserve user-modified runtime config — only update identity
476            // fields (name, icon, description). Everything the user can
477            // change in the Agent settings UI stays as-is.
478            agent.provider = existing_agent.provider.clone();
479            agent.agent_type = existing_agent.agent_type.clone();
480            agent.environment = existing_agent.environment.clone();
481            agent.model_vendor_base_url = existing_agent.model_vendor_base_url.clone();
482            agent.shell = if existing_agent.shell.is_empty() {
483                agent_def.shell.clone()
484            } else {
485                existing_agent.shell.clone()
486            };
487            agent.auto_start = existing_agent.auto_start;
488            agent.restart_on_crash = existing_agent.restart_on_crash;
489            agent.created_at = existing_agent.created_at;
490            agent.accounts = existing_agent.accounts.clone();
491            // Phase 2: preserve the user's hide preference across a
492            // manifest re-sync for templates that already exist on disk
493            // (the user may have explicitly hidden this one). The
494            // newly-added branch below keeps user_hidden = 0 so a never-
495            // before-seen template id always surfaces at least once.
496            agent.user_hidden = existing_agent.user_hidden;
497            mstore.agent_def_update(&mut agent)?;
498            updated += 1;
499        } else {
500            mstore.agent_def_insert(&mut agent)?;
501            created += 1;
502        }
503    }
504
505    // Remove seeded agents not in manifest (e.g., agent4, agent5)
506    for agent in &existing {
507        if agent.is_seeded == 1 && !manifest_ids.contains(agent.id.as_str()) {
508            mstore.agent_def_delete(&agent.id)?;
509            removed += 1;
510            tracing::info!("agent seed: removed seeded agent '{}'", agent.id);
511        }
512    }
513
514    Ok(Some(ReseedReport { created, updated, removed }))
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::backend::storage::store::{AgentDefinition, Store};
521
522    /// Helper to build a manifest in-memory with a fixed set of agents.
523    /// `reseed_if_needed` reads `manifest.agents`; we construct the
524    /// struct directly so the test doesn't have to round-trip JSON.
525    fn manifest_with(ids_and_descriptions: &[(&str, &str)]) -> SeedManifest {
526        SeedManifest {
527            version: 999,
528            agents: ids_and_descriptions
529                .iter()
530                .map(|(id, desc)| SeedAgent {
531                    id: id.to_string(),
532                    name: id.to_string(),
533                    icon: default_icon(),
534                    provider: default_provider(),
535                    agent_type: default_agent_type(),
536                    environment: default_environment(),
537                    description: desc.to_string(),
538                    working_directory: String::new(),
539                    shell: String::new(),
540                    agent_bus_id: String::new(),
541                    container_image: String::new(),
542                    auto_start: false,
543                    restart_on_crash: false,
544                    content: SeedContent::default(),
545                    skills: Vec::new(),
546                })
547                .collect(),
548            memories: Vec::new(),
549        }
550    }
551
552    fn insert_tpl(mstore: &Arc<Store>, id: &str, name: &str, hidden: i64) {
553        let mut def = AgentDefinition {
554            id: id.to_string(),
555            slug: id.to_string(),
556            name: name.to_string(),
557            icon: "✦".to_string(),
558            provider: "claude".to_string(),
559            description: "v1 desc".to_string(),
560            working_directory: String::new(),
561            shell: String::new(),
562            provider_flags: String::new(),
563            auto_start: 0,
564            restart_on_crash: 0,
565            idle_timeout_minutes: 0,
566            created_at: 1_700_000_000_000,
567            agent_type: "host".to_string(),
568            environment: String::new(),
569            agent_bus_id: String::new(),
570            is_seeded: 1,
571            accounts: String::new(),
572            parent_id: String::new(),
573            branch_label: String::new(),
574            updated_at: 1_700_000_000_000,
575            user_hidden: hidden,
576            container_image: String::new(),
577            container_volumes: "[]".to_string(),
578            container_name: String::new(),
579            use_ambient_login: 0,
580            auto_continue_enabled: 0,
581            model_vendor_base_url: String::new(),
582            memory_id: String::new(),
583            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
584        };
585        mstore.agent_def_insert(&mut def).unwrap();
586    }
587
588    #[test]
589    fn reseed_preserves_user_hidden_on_existing_templates() {
590        // The user previously hid `tpl-claude`. A description-only
591        // manifest change triggers a re-seed; the user's hide preference
592        // MUST survive (it's a per-user UI flag, not manifest-managed).
593        let mstore = Arc::new(Store::open_in_memory().unwrap());
594        insert_tpl(&mstore, "tpl-claude", "Claude", 1);
595        // Manifest carries a *different* description so reseed_if_needed
596        // sees a change and runs the upsert path.
597        let manifest = manifest_with(&[("tpl-claude", "v2 desc")]);
598
599        let report = reseed_if_needed(&mstore, &manifest)
600            .expect("reseed succeeds")
601            .expect("reseed runs because description changed");
602        assert_eq!(report.created, 0);
603        assert_eq!(report.updated, 1);
604
605        let after = mstore.agent_def_list().unwrap();
606        let tpl = after.iter().find(|a| a.id == "tpl-claude").unwrap();
607        assert_eq!(tpl.user_hidden, 1, "hide preference must survive reseed");
608        assert_eq!(tpl.description, "v2 desc", "description must update");
609    }
610
611    #[test]
612    fn reseed_resets_user_hidden_on_newly_added_template_id() {
613        // The user previously hid `tpl-claude`. A manifest update
614        // introduces a brand-new id `tpl-codex`. The new id MUST land
615        // with user_hidden = 0 — Phase 2 spec invariant so users always
616        // see new templates at least once.
617        let mstore = Arc::new(Store::open_in_memory().unwrap());
618        insert_tpl(&mstore, "tpl-claude", "Claude", 1);
619        let manifest = manifest_with(&[
620            ("tpl-claude", "v1 desc"), // unchanged — won't fire upsert on its own
621            ("tpl-codex", "Codex CLI"),  // NEW id — forces reseed
622        ]);
623
624        let report = reseed_if_needed(&mstore, &manifest)
625            .expect("reseed succeeds")
626            .expect("reseed runs because tpl-codex is new");
627        assert!(report.created >= 1, "tpl-codex should be inserted");
628
629        let after = mstore.agent_def_list().unwrap();
630        let codex = after
631            .iter()
632            .find(|a| a.id == "tpl-codex")
633            .expect("tpl-codex should now exist");
634        assert_eq!(
635            codex.user_hidden, 0,
636            "newly-added template must start visible (Phase 2 spec invariant)",
637        );
638        // And the previously-hidden one stays hidden.
639        let claude = after.iter().find(|a| a.id == "tpl-claude").unwrap();
640        assert_eq!(claude.user_hidden, 1);
641    }
642
643    // --- refreshing an untouched seeded bundle -----------------------------
644
645    fn sha_hex(text: &str) -> String {
646        use sha2::{Digest, Sha256};
647        hex::encode(Sha256::digest(text.as_bytes()))
648    }
649
650    fn bundle_manifest(new_text: &str, replaces: &[String]) -> SeedManifest {
651        serde_json::from_value(serde_json::json!({
652            "version": 1,
653            "agents": [],
654            "memories": [{
655                "id": "seed-test-bundle",
656                "name": "Test Bundle",
657                "instructions": new_text,
658                "replaces_instructions_sha256": replaces,
659            }],
660        }))
661        .unwrap()
662    }
663
664    fn instructions_of(mstore: &Arc<Store>) -> String {
665        mstore.bundle_get("seed-test-bundle").unwrap().unwrap().instructions
666    }
667
668    #[test]
669    fn an_untouched_earlier_seed_text_is_replaced() {
670        let mstore = Arc::new(Store::open_in_memory().unwrap());
671        seed_memories(&mstore, &bundle_manifest("old text", &[])).unwrap();
672        let n = seed_memories(&mstore, &bundle_manifest("new text", &[sha_hex("old text")])).unwrap();
673        assert_eq!(n, 1);
674        assert_eq!(instructions_of(&mstore), "new text");
675    }
676
677    #[test]
678    fn an_edited_bundle_is_left_alone() {
679        let mstore = Arc::new(Store::open_in_memory().unwrap());
680        seed_memories(&mstore, &bundle_manifest("old text, edited by the user", &[])).unwrap();
681        let n = seed_memories(&mstore, &bundle_manifest("new text", &[sha_hex("old text")])).unwrap();
682        assert_eq!(n, 0);
683        assert_eq!(instructions_of(&mstore), "old text, edited by the user");
684    }
685
686    #[test]
687    fn without_earlier_hashes_an_existing_bundle_is_never_touched() {
688        let mstore = Arc::new(Store::open_in_memory().unwrap());
689        seed_memories(&mstore, &bundle_manifest("old text", &[])).unwrap();
690        assert_eq!(seed_memories(&mstore, &bundle_manifest("new text", &[])).unwrap(), 0);
691        assert_eq!(instructions_of(&mstore), "old text");
692    }
693
694    #[test]
695    fn the_shipped_manifest_refreshes_the_old_reagent_text_of_the_dev_bundle() {
696        let manifest: SeedManifest = serde_json::from_str(SEED_MANIFEST).unwrap();
697        let dev = manifest.memories.iter().find(|m| m.id == "seed-agentmux-dev").unwrap();
698        assert!(!dev.replaces_instructions_sha256.is_empty());
699        assert!(dev.instructions.contains("### muxreview Bot"));
700        assert!(!dev.replaces_instructions_sha256.contains(&sha_hex(&dev.instructions)),
701            "the current text must not be listed as an earlier one");
702    }
703}