Skip to main content

agentmux_srv/backend/storage/
def_registry_mirror.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Global agent-definition registry mirror.
5//!
6//! Mirrors `db_agent_definitions` mutations (with the agent's content +
7//! skills) into the GLOBAL definition store at
8//! `~/.agentmux/shared/agents/definitions/` so an agent created in one
9//! channel is visible in every channel. SQLite remains authoritative for
10//! the local channel; the global store is the cross-channel view.
11//! Best-effort: every failure is logged, never propagated. Sibling of
12//! `registry_mirror.rs` (the named-instance mirror).
13//!
14//! Cross-channel agent persistence, P0.2b
15//! (`docs/specs/SPEC_CROSS_CHANNEL_AGENT_PERSISTENCE_2026-06-13.md`).
16
17use crate::registry::{
18    DefContentBlob, DefSkillBlob, DefinitionRecord, DefinitionRecordV1, DEF_MAX_SUPPORTED_SCHEMA,
19};
20
21use super::agents::AgentDefinition;
22use super::content::AgentContent;
23use super::error::StoreError;
24use super::skills::AgentSkill;
25use super::store::Store;
26
27/// Build a global definition record from a definition row + its content and
28/// skills (which live in separate tables and must travel with the
29/// definition for a cross-channel agent to launch with its instructions).
30pub(super) fn agent_definition_to_record(
31    def: &AgentDefinition,
32    content: &[AgentContent],
33    skills: &[AgentSkill],
34) -> DefinitionRecord {
35    let data = DefinitionRecordV1 {
36        id: def.id.clone(),
37        slug: def.slug.clone(),
38        name: def.name.clone(),
39        icon: def.icon.clone(),
40        provider: def.provider.clone(),
41        description: def.description.clone(),
42        working_directory: def.working_directory.clone(),
43        shell: def.shell.clone(),
44        provider_flags: def.provider_flags.clone(),
45        auto_start: def.auto_start,
46        restart_on_crash: def.restart_on_crash,
47        idle_timeout_minutes: def.idle_timeout_minutes,
48        created_at: def.created_at,
49        agent_type: def.agent_type.clone(),
50        environment: def.environment.clone(),
51        agent_bus_id: def.agent_bus_id.clone(),
52        is_seeded: def.is_seeded,
53        accounts: def.accounts.clone(),
54        parent_id: def.parent_id.clone(),
55        branch_label: def.branch_label.clone(),
56        updated_at: def.updated_at,
57        user_hidden: def.user_hidden,
58        container_image: def.container_image.clone(),
59        container_volumes: def.container_volumes.clone(),
60        container_name: def.container_name.clone(),
61        use_ambient_login: def.use_ambient_login,
62        auto_continue_enabled: def.auto_continue_enabled,
63        content: content
64            .iter()
65            .map(|c| DefContentBlob {
66                content_type: c.content_type.clone(),
67                content: c.content.clone(),
68            })
69            .collect(),
70        skills: skills
71            .iter()
72            .map(|s| DefSkillBlob {
73                id: s.id.clone(),
74                name: s.name.clone(),
75                trigger: s.trigger.clone(),
76                skill_type: s.skill_type.clone(),
77                description: s.description.clone(),
78                content: s.content.clone(),
79            })
80            .collect(),
81    };
82    DefinitionRecord {
83        schema_version: DEF_MAX_SUPPORTED_SCHEMA,
84        data,
85    }
86}
87
88/// Reconstruct an `AgentDefinition` from a global record (inverse of
89/// [`agent_definition_to_record`]) — the read path for a cross-channel
90/// agent whose definition isn't in the local channel's SQLite. Content +
91/// skills are carried separately on the record and surfaced via the
92/// content/skills read fallbacks.
93pub(super) fn record_to_agent_definition(rec: &DefinitionRecord) -> AgentDefinition {
94    let d = &rec.data;
95    AgentDefinition {
96        id: d.id.clone(),
97        slug: d.slug.clone(),
98        name: d.name.clone(),
99        icon: d.icon.clone(),
100        provider: d.provider.clone(),
101        description: d.description.clone(),
102        working_directory: d.working_directory.clone(),
103        shell: d.shell.clone(),
104        provider_flags: d.provider_flags.clone(),
105        auto_start: d.auto_start,
106        restart_on_crash: d.restart_on_crash,
107        idle_timeout_minutes: d.idle_timeout_minutes,
108        created_at: d.created_at,
109        agent_type: d.agent_type.clone(),
110        environment: d.environment.clone(),
111        agent_bus_id: d.agent_bus_id.clone(),
112        is_seeded: d.is_seeded,
113        accounts: d.accounts.clone(),
114        parent_id: d.parent_id.clone(),
115        branch_label: d.branch_label.clone(),
116        updated_at: d.updated_at,
117        user_hidden: d.user_hidden,
118        container_image: d.container_image.clone(),
119        container_volumes: d.container_volumes.clone(),
120        container_name: d.container_name.clone(),
121        use_ambient_login: d.use_ambient_login,
122        // Deliberately not carried by DefinitionRecordV1 (see its module
123        // doc) — a model vendor override is channel-local only for now;
124        // a cross-channel agent reopened from the global registry starts
125        // with the harness's default vendor. Known limitation, not a bug.
126        model_vendor_base_url: String::new(),
127        auto_continue_enabled: d.auto_continue_enabled,
128        // Same known limitation as model_vendor_base_url just above —
129        // DefinitionRecordV1 doesn't carry it yet, so a cross-channel
130        // reopen starts unbound. m0021's backfill only ever sees the LOCAL
131        // channel's SQLite for this reason; a genuinely global backfill
132        // needs this field added to the registry wire format first.
133        memory_id: String::new(),
134        // Same known limitation, but the safe direction: DefinitionRecordV1
135        // doesn't carry it yet, so a cross-channel-reopened agent starts
136        // back at the fail-closed "private" default rather than an unset/
137        // empty value — never silently more permissive than intended.
138        // agent_def_list()'s own overlay (agents/definition.rs) preserves the local
139        // row's real value when one exists, same as model_vendor_base_url/
140        // memory_id; this default only applies to a genuinely
141        // never-seen-locally cross-channel agent.
142        conversation_visibility: super::agents::default_conversation_visibility(),
143    }
144}
145
146impl Store {
147    /// Mirror a definition (by id) into the global store, reading its full
148    /// row + content + skills from SQLite. Best-effort: a missing global
149    /// store or any failure is logged, never propagated (SQLite stays
150    /// authoritative). No-op when the definition no longer exists.
151    ///
152    /// `upsert` itself refuses to resurrect a tombstoned id, so a stale
153    /// mirror call for a deleted agent won't un-delete it.
154    pub(super) fn registry_def_upsert(&self, def_id: &str) {
155        let Some(reg) = self.shared_def_registry() else {
156            return;
157        };
158        let def = match self.agent_def_get(def_id) {
159            Ok(Some(d)) => d,
160            Ok(None) => return,
161            Err(e) => {
162                tracing::warn!(def_id, error = %e, "def registry: read definition failed, skipping mirror");
163                return;
164            }
165        };
166        // Only USER agents go to the global cross-channel store. Seeded
167        // templates are manifest-managed and re-seeded per channel (and
168        // their hide flag is per-channel UI state), so they stay local.
169        if def.is_seeded != 0 {
170            return;
171        }
172        // LOCAL-only reads: the mirror operates on a local agent, so it must
173        // never read the global record (that would re-mirror just-deleted
174        // content/skills back, defeating the deletion). (reagent P1 on #1385.)
175        let content = self.agent_content_get_all_local(def_id).unwrap_or_default();
176        let skills = self.agent_skill_list_local(def_id).unwrap_or_default();
177        let rec = agent_definition_to_record(&def, &content, &skills);
178        if let Err(e) = reg.upsert(&rec) {
179            tracing::warn!(def_id, error = %e, "def registry: mirror upsert failed");
180        }
181    }
182
183    /// Same mirror as `registry_def_upsert`, but never CREATES a global
184    /// record — only refreshes one that already exists. Used by
185    /// `agent_content_set`/`agent_content_delete`/the skills equivalents:
186    /// their job is "keep an EXISTING mirror's content/skills in sync,"
187    /// never "make this agent globally mirrored for the first time" (that
188    /// is `agent_def_insert`/`agent_def_update`/`agent_def_find_or_insert`'s
189    /// job, and their own `registry_def_upsert` calls already cover it).
190    ///
191    /// Load-bearing as of the definition flip
192    /// (`SPEC_AGENT_ARCHITECTURE_2026_05_27.md` Phase 3d, reagent P1 x2 on
193    /// #3092): `agent_def_get` now resolves a template LAUNCH's own row
194    /// too (is_seeded = 0, indistinguishable by column shape from a genuine
195    /// user agent — `instance_create`'s fresh-launch INSERT sets both
196    /// `is_template`/`is_seeded` to 0 the same way `agent_def_insert` does
197    /// for a real one). A launch never goes through `agent_def_insert`, so
198    /// it never has an existing global record — routing content/skill
199    /// writes through `registry_def_upsert`'s unconditional create-or-update
200    /// would mirror the launch into the shared cross-channel registry the
201    /// first time anything wrote its content (e.g. a routine `ui:zoom`
202    /// setting), making it appear as a new, permanent "My Agents" entry in
203    /// every other channel — one ghost per launch, accumulating forever.
204    /// This is not a template-vs-launch distinction this function has to
205    /// make: skipping creation entirely is correct for BOTH (a genuine
206    /// template's own content writes are also a no-op here, same as
207    /// `registry_def_upsert`'s explicit `is_seeded` check already intends).
208    pub(super) fn registry_def_refresh_if_mirrored(&self, def_id: &str) {
209        let Some(reg) = self.shared_def_registry() else {
210            return;
211        };
212        if !reg.exists(def_id) {
213            return;
214        }
215        let def = match self.agent_def_get(def_id) {
216            Ok(Some(d)) => d,
217            Ok(None) => return,
218            Err(e) => {
219                tracing::warn!(def_id, error = %e, "def registry: read definition failed, skipping refresh");
220                return;
221            }
222        };
223        if def.is_seeded != 0 {
224            return;
225        }
226        let content = self.agent_content_get_all_local(def_id).unwrap_or_default();
227        let skills = self.agent_skill_list_local(def_id).unwrap_or_default();
228        let rec = agent_definition_to_record(&def, &content, &skills);
229        if let Err(e) = reg.upsert(&rec) {
230            tracing::warn!(def_id, error = %e, "def registry: mirror refresh failed");
231        }
232    }
233
234    /// Mirror a user-agent deletion as a global tombstone (`retired/`) so
235    /// another channel's stale SQLite can't resurrect it via `upsert`.
236    /// Best-effort.
237    pub(super) fn registry_def_retire(&self, def_id: &str) -> bool {
238        let Some(reg) = self.shared_def_registry() else {
239            return false;
240        };
241        // Whether an ACTIVE global record existed — lets the delete path
242        // report success for a cross-channel agent that has no local SQLite
243        // row. (codex P1 on #1385.)
244        let existed = reg.exists(def_id);
245        if let Err(e) = reg.retire(def_id) {
246            tracing::warn!(def_id, error = %e, "def registry: mirror retire failed");
247            return false;
248        }
249        existed
250    }
251
252    /// Apply a definition edit directly to the global record's definition
253    /// fields, PRESERVING its existing content + skills. Used when editing a
254    /// cross-channel agent that has no local SQLite row to update. Returns
255    /// whether a global record was found + updated. Best-effort. Tombstoned
256    /// (retired) agents are not resurrected — `get` reads only the active tree.
257    pub(super) fn registry_def_update_definition_fields(&self, agent: &AgentDefinition) -> bool {
258        let Some(reg) = self.shared_def_registry() else {
259            return false;
260        };
261        let mut rec = match reg.get(&agent.id) {
262            Ok(Some(r)) => r,
263            _ => return false,
264        };
265        // Keep the record's content + skills; replace the definition fields.
266        let kept_content = std::mem::take(&mut rec.data.content);
267        let kept_skills = std::mem::take(&mut rec.data.skills);
268        let mut new_rec = agent_definition_to_record(agent, &[], &[]);
269        new_rec.data.content = kept_content;
270        new_rec.data.skills = kept_skills;
271        if let Err(e) = reg.upsert(&new_rec) {
272            tracing::warn!(def_id = %agent.id, error = %e, "def registry: cross-channel update failed");
273            return false;
274        }
275        true
276    }
277
278    /// Update a single content blob in the global registry record for a
279    /// cross-channel agent, preserving all other definition fields, content, and
280    /// skills. Used when the local channel has no `db_agent_definitions` row for
281    /// the agent (FK would prevent a local SQLite write).
282    ///
283    /// `value = Some(s)` upserts the blob; `value = None` removes it.
284    /// Returns `Err` if the global registry is unavailable or the agent is absent.
285    pub(super) fn registry_def_update_content_field(
286        &self,
287        agent_id: &str,
288        content_type: &str,
289        value: Option<&str>,
290    ) -> Result<(), StoreError> {
291        let Some(reg) = self.shared_def_registry() else {
292            return Err(StoreError::Other("no global registry available".into()));
293        };
294        let mut rec = match reg.get(agent_id) {
295            Ok(Some(r)) => r,
296            Ok(None) => {
297                return Err(StoreError::Other(format!(
298                    "agent {agent_id} not in global registry"
299                )))
300            }
301            Err(e) => {
302                return Err(StoreError::Other(format!(
303                    "global registry read failed: {e}"
304                )))
305            }
306        };
307        match value {
308            Some(v) => {
309                if let Some(blob) = rec.data.content.iter_mut().find(|b| b.content_type == content_type) {
310                    blob.content = v.to_string();
311                } else {
312                    rec.data.content.push(DefContentBlob {
313                        content_type: content_type.to_string(),
314                        content: v.to_string(),
315                    });
316                }
317            }
318            None => {
319                rec.data.content.retain(|b| b.content_type != content_type);
320            }
321        }
322        reg.upsert(&rec)
323            .map_err(|e| StoreError::Other(format!("global registry write failed: {e}")))?;
324        Ok(())
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::registry::{
332        DefContentBlob, DefSkillBlob, DefinitionRecord, DefinitionRecordV1, DefinitionStore,
333        DEF_MAX_SUPPORTED_SCHEMA,
334    };
335    use std::sync::Arc;
336
337    fn global_user_agent(id: &str, name: &str) -> DefinitionRecord {
338        DefinitionRecord {
339            schema_version: DEF_MAX_SUPPORTED_SCHEMA,
340            data: DefinitionRecordV1 {
341                id: id.to_string(),
342                name: name.to_string(),
343                provider: "claude".to_string(),
344                is_seeded: 0,
345                content: vec![DefContentBlob {
346                    content_type: "agentmd".to_string(),
347                    content: "be helpful".to_string(),
348                }],
349                skills: vec![DefSkillBlob {
350                    id: "sk1".to_string(),
351                    name: "greet".to_string(),
352                    ..Default::default()
353                }],
354                ..Default::default()
355            },
356        }
357    }
358
359    #[test]
360    fn read_first_surfaces_cross_channel_agent_with_content_and_skills() {
361        let store = Store::open_in_memory().unwrap();
362        let tmp = tempfile::tempdir().unwrap();
363        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
364        // A user agent that exists only in the global store (another channel).
365        def_store
366            .upsert(&global_user_agent("remote-1", "Remote"))
367            .unwrap();
368        store.set_def_registry(def_store);
369
370        // Roster includes the cross-channel agent (it's not in local SQLite).
371        let list = store.agent_def_list().unwrap();
372        assert!(
373            list.iter().any(|d| d.id == "remote-1" && d.name == "Remote"),
374            "agent_def_list must include the global-only user agent"
375        );
376
377        // Content + skills fall back to the global record (local SQLite empty),
378        // so the cross-channel agent launches with its instructions.
379        let content = store.agent_content_get_all("remote-1").unwrap();
380        assert_eq!(content.len(), 1);
381        assert_eq!(content[0].content, "be helpful");
382        let skills = store.agent_skill_list("remote-1").unwrap();
383        assert_eq!(skills.len(), 1);
384        assert_eq!(skills[0].name, "greet");
385    }
386
387    // reagent P0 on PR #2505: agent_def_list's global overlay unconditionally
388    // inserted the registry-derived def over the local one for every user
389    // agent id — including a same-channel agent that's ALSO mirrored into
390    // the global store (the normal case: every agent.define write
391    // auto-mirrors via registry_def_upsert). Since DefinitionRecordV1 never
392    // carries model_vendor_base_url (deliberately channel-local only), the
393    // overlay's always-"" value silently erased the local override on every
394    // read — including agent.open's spawn-time resolution — making the
395    // whole feature a no-op in default single-instance operation, not just
396    // the documented genuinely-cross-channel case.
397    #[test]
398    fn agent_def_list_preserves_local_model_vendor_base_url_over_the_global_overlay() {
399        let store = Store::open_in_memory().unwrap();
400        let tmp = tempfile::tempdir().unwrap();
401        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
402
403        let mut def = local_def("agent-1", "Vendor Agent");
404        def.model_vendor_base_url = "https://my-proxy.example.com".to_string();
405        store.agent_def_insert(&mut def).unwrap();
406        // Same id also present in the global store — the normal case, not
407        // the cross-channel-only case the earlier (buggy) comment assumed.
408        def_store.upsert(&global_user_agent("agent-1", "Vendor Agent")).unwrap();
409        store.set_def_registry(def_store);
410
411        let list = store.agent_def_list().unwrap();
412        let found = list.iter().find(|d| d.id == "agent-1").expect("agent must be listed");
413        assert_eq!(
414            found.model_vendor_base_url, "https://my-proxy.example.com",
415            "the global overlay must not wipe a same-channel agent's vendor override"
416        );
417    }
418
419    // P0 regression test (ReAgent review on PR #2587 round 6): the
420    // IDENTICAL bug as the model_vendor_base_url test just above,
421    // for memory_id — DefinitionRecordV1 doesn't carry memory_id either
422    // (same documented gap), so record_to_agent_definition always
423    // returns "" for it, and the overlay used to overwrite the local
424    // row's real memory_id with that empty default on every read. Since
425    // every local write auto-mirrors into the global registry, this
426    // fired for virtually every agent whenever a shared store is
427    // configured — silently defeating agent_open.rs's spawn-time bundle
428    // resolution and m0021's memory_id-empty backfill filter.
429    #[test]
430    fn agent_def_list_preserves_local_memory_id_over_the_global_overlay() {
431        let store = Store::open_in_memory().unwrap();
432        let tmp = tempfile::tempdir().unwrap();
433        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
434
435        let mut def = local_def("agent-1", "Bundle Agent");
436        store.agent_def_insert(&mut def).unwrap();
437        store.agent_def_set_memory_id_if_empty("agent-1", "bundle-123").unwrap();
438        // Same id also present in the global store — the normal case.
439        def_store.upsert(&global_user_agent("agent-1", "Bundle Agent")).unwrap();
440        store.set_def_registry(def_store);
441
442        let list = store.agent_def_list().unwrap();
443        let found = list.iter().find(|d| d.id == "agent-1").expect("agent must be listed");
444        assert_eq!(
445            found.memory_id, "bundle-123",
446            "the global overlay must not wipe a same-channel agent's bundle binding"
447        );
448    }
449
450    fn local_def(id: &str, name: &str) -> AgentDefinition {
451        AgentDefinition {
452            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
453            id: id.to_string(),
454            slug: id.to_string(),
455            name: name.to_string(),
456            icon: "✦".to_string(),
457            provider: "claude".to_string(),
458            description: String::new(),
459            working_directory: String::new(),
460            shell: String::new(),
461            provider_flags: String::new(),
462            auto_start: 0,
463            restart_on_crash: 0,
464            idle_timeout_minutes: 0,
465            created_at: 1,
466            agent_type: "host".to_string(),
467            environment: String::new(),
468            agent_bus_id: String::new(),
469            is_seeded: 0,
470            accounts: String::new(),
471            parent_id: String::new(),
472            branch_label: String::new(),
473            updated_at: 1,
474            user_hidden: 0,
475            container_image: String::new(),
476            container_volumes: "[]".to_string(),
477            container_name: String::new(),
478            use_ambient_login: 0,
479            model_vendor_base_url: String::new(),
480            auto_continue_enabled: 0,
481            memory_id: String::new(),
482        }
483    }
484
485    fn skill(id: &str, agent_id: &str, name: &str) -> AgentSkill {
486        AgentSkill {
487            id: id.to_string(),
488            agent_id: agent_id.to_string(),
489            name: name.to_string(),
490            trigger: String::new(),
491            skill_type: "prompt".to_string(),
492            description: String::new(),
493            content: String::new(),
494            created_at: 1,
495        }
496    }
497
498    #[test]
499    fn local_skill_delete_does_not_resurrect_from_global() {
500        let store = Store::open_in_memory().unwrap();
501        let tmp = tempfile::tempdir().unwrap();
502        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
503        store.set_def_registry(def_store.clone());
504
505        // Create a LOCAL user agent with one skill (mirrored to global).
506        let mut def = local_def("local-1", "Local");
507        store.agent_def_insert(&mut def).unwrap();
508        store
509            .agent_skill_insert(&skill("sk-1", "local-1", "greet"))
510            .unwrap();
511        assert_eq!(
512            def_store.get("local-1").unwrap().unwrap().data.skills.len(),
513            1,
514            "skill mirrored to global"
515        );
516
517        // Delete the skill in this (local) channel.
518        store.agent_skill_delete("sk-1").unwrap();
519
520        // Local read must NOT resurrect the deleted skill from the global
521        // record (the agent is local, just skill-less now). (reagent P1.)
522        assert!(
523            store.agent_skill_list("local-1").unwrap().is_empty(),
524            "deleted local skill must not reappear from the global record"
525        );
526        // And the deletion propagated to the global record (the mirror used a
527        // local-only read, so it didn't re-write the stale skill).
528        assert!(
529            def_store.get("local-1").unwrap().unwrap().data.skills.is_empty(),
530            "deletion must propagate cross-channel"
531        );
532    }
533
534    #[test]
535    fn local_content_delete_propagates_to_global() {
536        let store = Store::open_in_memory().unwrap();
537        let tmp = tempfile::tempdir().unwrap();
538        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
539        store.set_def_registry(def_store.clone());
540
541        // LOCAL user agent with a per-agent ui:zoom blob (mirrored to global).
542        let mut def = local_def("local-z", "LocalZ");
543        store.agent_def_insert(&mut def).unwrap();
544        store
545            .agent_content_set(&AgentContent {
546                agent_id: "local-z".into(),
547                content_type: "ui:zoom".into(),
548                content: "1.5".into(),
549                updated_at: 1,
550            })
551            .unwrap();
552        assert!(
553            def_store
554                .get("local-z")
555                .unwrap()
556                .unwrap()
557                .data
558                .content
559                .iter()
560                .any(|c| c.content_type == "ui:zoom"),
561            "content mirrored to global"
562        );
563
564        // Reset-to-default deletes the local row.
565        store.agent_content_delete("local-z", "ui:zoom").unwrap();
566
567        // Local read is clean (no resurrection from the global record)...
568        assert!(
569            store.agent_content_get("local-z", "ui:zoom").unwrap().is_none(),
570            "deleted local content must not reappear from the global record"
571        );
572        // ...and the deletion propagated to the global record, so a
573        // cross-channel/other-instance reopen won't restore the stale zoom.
574        // (reviewer P1 on #1700 — agent_content_delete must re-mirror, like set.)
575        assert!(
576            def_store
577                .get("local-z")
578                .unwrap()
579                .unwrap()
580                .data
581                .content
582                .iter()
583                .all(|c| c.content_type != "ui:zoom"),
584            "content deletion must propagate cross-channel"
585        );
586    }
587
588    #[test]
589    fn delete_tombstones_a_cross_channel_only_agent() {
590        let store = Store::open_in_memory().unwrap();
591        let tmp = tempfile::tempdir().unwrap();
592        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
593        // A user agent that exists ONLY in the global store (no local SQLite row).
594        def_store
595            .upsert(&global_user_agent("remote-1", "Remote"))
596            .unwrap();
597        store.set_def_registry(def_store.clone());
598        // It appears in the roster via the global overlay.
599        assert!(store.agent_def_list().unwrap().iter().any(|d| d.id == "remote-1"));
600
601        // Deleting it (rows == 0 locally) must tombstone the global record and
602        // report success — not silently leave it to reappear. (codex P1.)
603        let deleted = store.agent_def_delete("remote-1").unwrap();
604        assert!(deleted, "cross-channel delete must report success");
605        assert!(!def_store.exists("remote-1"), "global record tombstoned");
606        assert!(
607            !store.agent_def_list().unwrap().iter().any(|d| d.id == "remote-1"),
608            "deleted cross-channel agent must not reappear in the roster"
609        );
610    }
611
612    #[test]
613    fn update_edits_a_cross_channel_only_agent_preserving_content_skills() {
614        let store = Store::open_in_memory().unwrap();
615        let tmp = tempfile::tempdir().unwrap();
616        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
617        // Global-only agent WITH content + skills.
618        def_store
619            .upsert(&global_user_agent("remote-1", "Remote"))
620            .unwrap();
621        store.set_def_registry(def_store.clone());
622
623        // Edit it (no local SQLite row → UPDATE affects 0 rows).
624        let mut edited = record_to_agent_definition(&global_user_agent("remote-1", "Remote"));
625        edited.name = "Renamed".to_string();
626        let ok = store.agent_def_update(&mut edited).unwrap();
627        assert!(ok, "cross-channel update must report success, not 'not found'");
628
629        // Global record reflects the edit; content + skills are preserved.
630        let rec = def_store.get("remote-1").unwrap().unwrap();
631        assert_eq!(rec.data.name, "Renamed");
632        assert_eq!(rec.data.content.len(), 1, "content preserved");
633        assert_eq!(rec.data.skills.len(), 1, "skills preserved");
634        // The roster shows the new name.
635        assert!(store
636            .agent_def_list()
637            .unwrap()
638            .iter()
639            .any(|d| d.id == "remote-1" && d.name == "Renamed"));
640    }
641
642    #[test]
643    fn cross_channel_content_set_writes_to_global_registry() {
644        // Regression test for #1700: agent_content_set for a cross-channel agent
645        // (one absent from local db_agent_definitions) must write directly to the
646        // global registry rather than failing with an FK violation.
647        let store = Store::open_in_memory().unwrap();
648        let tmp = tempfile::tempdir().unwrap();
649        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
650        def_store
651            .upsert(&global_user_agent("remote-1", "Remote"))
652            .unwrap();
653        store.set_def_registry(def_store.clone());
654
655        // Write ui:zoom for the cross-channel agent — must not fail.
656        store
657            .agent_content_set(&AgentContent {
658                agent_id: "remote-1".into(),
659                content_type: "ui:zoom".into(),
660                content: "1.4".into(),
661                updated_at: 100,
662            })
663            .unwrap();
664
665        // The global record must now carry the zoom (other content preserved).
666        let rec = def_store.get("remote-1").unwrap().unwrap();
667        let zoom_blob = rec.data.content.iter().find(|c| c.content_type == "ui:zoom");
668        assert!(zoom_blob.is_some(), "ui:zoom must be in the global record");
669        assert_eq!(zoom_blob.unwrap().content, "1.4");
670        let agentmd_blob = rec.data.content.iter().find(|c| c.content_type == "agentmd");
671        assert!(agentmd_blob.is_some(), "existing agentmd content must be preserved");
672
673        // agent_content_get must surface it via the cross-channel fallback.
674        let got = store.agent_content_get("remote-1", "ui:zoom").unwrap();
675        assert!(got.is_some(), "agent_content_get must find the zoom via registry fallback");
676        assert_eq!(got.unwrap().content, "1.4");
677    }
678
679    #[test]
680    fn cross_channel_content_delete_removes_from_global_registry() {
681        // Regression test for #1700: agent_content_delete for a cross-channel agent
682        // must remove the field from the global registry without corrupting other content.
683        let store = Store::open_in_memory().unwrap();
684        let tmp = tempfile::tempdir().unwrap();
685        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
686        // Start with a global record that already has ui:zoom set.
687        let mut rec = global_user_agent("remote-1", "Remote");
688        rec.data.content.push(DefContentBlob {
689            content_type: "ui:zoom".into(),
690            content: "1.4".into(),
691        });
692        def_store.upsert(&rec).unwrap();
693        store.set_def_registry(def_store.clone());
694
695        // Delete the zoom (reset-to-default path).
696        store.agent_content_delete("remote-1", "ui:zoom").unwrap();
697
698        // The zoom blob must be gone, other content intact.
699        let after = def_store.get("remote-1").unwrap().unwrap();
700        assert!(
701            after.data.content.iter().all(|c| c.content_type != "ui:zoom"),
702            "ui:zoom must be removed from global record"
703        );
704        assert!(
705            after.data.content.iter().any(|c| c.content_type == "agentmd"),
706            "agentmd content must be preserved"
707        );
708
709        // agent_content_get must return None now.
710        assert!(
711            store.agent_content_get("remote-1", "ui:zoom").unwrap().is_none(),
712            "deleted zoom must not reappear"
713        );
714    }
715
716    /// reagent P1 x2 on #3092: a bare template-launch row (`is_seeded = 0`,
717    /// same shape as a genuine user agent — it never went through
718    /// `agent_def_insert`) must never be mirrored into the global registry
719    /// for the FIRST time by a routine content write, e.g. the `ui:zoom`
720    /// pane setting. Before the definition flip this was structurally
721    /// impossible (`agent_def_get` 404'd for a launch id, so
722    /// `registry_def_upsert`'s own read failed and it returned early);
723    /// once `agent_def_get` started resolving launch rows too, the same
724    /// `is_seeded` check no longer excluded them, and every launch that
725    /// ever had content/skills written on it would appear as a new,
726    /// permanent "My Agents" entry in every other channel sharing the
727    /// registry — one ghost per launch. `registry_def_refresh_if_mirrored`
728    /// closes this without inventing a template-vs-launch distinction at
729    /// all: it simply never CREATES a global record, only refreshes one
730    /// that's already there — which a raw launch's never is.
731    #[test]
732    fn a_template_launchs_first_content_write_does_not_create_a_global_registry_entry() {
733        let store = Store::open_in_memory().unwrap();
734        let tmp = tempfile::tempdir().unwrap();
735        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
736        store.set_def_registry(def_store.clone());
737
738        let mut tpl = local_def("tpl-1", "Coder");
739        tpl.is_seeded = 1;
740        store.agent_def_insert(&mut tpl).unwrap();
741        // Sanity: seeded templates never mirror globally either.
742        assert!(!def_store.exists("tpl-1"));
743
744        let launch = crate::backend::storage::store::AgentInstance {
745            id: "launch-1".to_string(),
746            definition_id: "tpl-1".to_string(),
747            parent_instance_id: String::new(),
748            block_id: String::new(),
749            session_id: String::new(),
750            status: "running".to_string(),
751            github_context: String::new(),
752            started_at: 1,
753            ended_at: 0,
754            created_at: 1,
755            identity_id: String::new(),
756            memory_id: String::new(),
757            instance_name: "Coder".to_string(),
758            working_directory: String::new(),
759            display_hidden: false,
760        };
761        let canonical = store.instance_create(&launch).unwrap();
762        assert_eq!(canonical.id, "launch-1", "sanity: a template launch creates its own row");
763
764        // The routine write a real launch does all the time — e.g. the pane
765        // recording its zoom level.
766        store
767            .agent_content_set(&crate::backend::storage::store::AgentContent {
768                agent_id: "launch-1".to_string(),
769                content_type: "ui:zoom".to_string(),
770                content: "1.4".to_string(),
771                updated_at: 2,
772            })
773            .unwrap();
774
775        assert!(
776            !def_store.exists("launch-1"),
777            "a template launch's content write must never create a global registry entry",
778        );
779        // The feature itself still works — the write landed locally.
780        let local = store.agent_content_get("launch-1", "ui:zoom").unwrap();
781        assert_eq!(local.map(|c| c.content), Some("1.4".to_string()));
782    }
783}