Skip to main content

mur_common/agent/
mod.rs

1//! Agent profile, Agent Card, and LockFile types shared between
2//! mur-agent-runtime and mur-core.
3
4use crate::companion::{Formality, Relationship};
5use crate::deps::ProgramDep;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9mod companion;
10mod entitlements;
11mod lifecycle;
12mod mcp;
13mod transport;
14
15pub use companion::*;
16pub use entitlements::*;
17pub use lifecycle::*;
18pub use mcp::*;
19pub use transport::*;
20
21/// Skill metadata broadcast in the Agent Card (Layer 1 + Layer 2).
22///
23/// Populated by `mur skill install` (registry or agent:// URL). Distinct from
24/// `AgentProfile.skills`, which is the legacy per-agent-path list managed by
25/// `mur agent skill add`.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
27pub struct SkillCardEntry {
28    pub name: String,
29    #[serde(default, skip_serializing_if = "String::is_empty")]
30    pub version: String,
31    #[serde(default, skip_serializing_if = "String::is_empty")]
32    pub publisher: String,
33    #[serde(default, skip_serializing_if = "String::is_empty")]
34    pub description: String,
35    #[serde(default, skip_serializing_if = "String::is_empty")]
36    pub category: String,
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub tags: Vec<String>,
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub triggers: Vec<SkillCardTrigger>,
41    /// Layer 2 abstract — injected at session start (~200 tokens).
42    /// On-disk YAML key is `abstract` (a Rust reserved word).
43    #[serde(default, skip_serializing_if = "String::is_empty", rename = "abstract")]
44    pub abstract_text: String,
45    /// Provenance chain copied from the installed manifest. Empty for
46    /// registry-installed skills.
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub transfer_chain: Vec<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
52pub struct SkillCardTrigger {
53    #[serde(rename = "type")]
54    pub kind: String,
55    #[serde(default, skip_serializing_if = "String::is_empty")]
56    pub pattern: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct AgentProfile {
61    pub schema: u32,
62    pub id: String, // UUIDv7
63    pub name: String,
64    pub display_name: String,
65    /// Coarse human-facing role for grouping/filtering (e.g. "Engineer").
66    /// A free label, not a registry — bundled defaults are UI suggestions and
67    /// users can type their own. Also the SOFT signal in the dispatch index
68    /// (`agent_facts`), where it explains and ranks candidates but never
69    /// filters them: what an agent may actually do is decided by
70    /// `entitlements`, which the kernel enforces and a stale label cannot
71    /// overstate.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub role: Option<String>,
74    /// How hard this agent's model should work per turn
75    /// (`low`/`medium`/`high`/`xhigh`/`max`). `None` leaves the field off,
76    /// which is the API default (`high`) — not "no effort".
77    ///
78    /// Set it where the agent's JOB is known: a single-purpose build
79    /// specialist earns `xhigh`, a fan-out research worker `medium`, a
80    /// classifier `low`. Narrowed to what the resolved model accepts at the
81    /// client boundary, so an agent pinned to an older model degrades rather
82    /// than 400s.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub effort: Option<crate::llm::Effort>,
85    pub version: String,
86    pub persona: Persona,
87    pub sys_prompt_file: String,
88    pub model: ModelConfig,
89    /// Optional pointer into ~/.mur/models.yaml. When set, the runtime
90    /// prefers the registry entry over the inline `model:` block.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub model_ref: Option<String>,
93    /// Per-agent fallback chain (ordered model_refs). Overrides the global
94    /// `models.fallback_chain` when non-empty. See the model-switch spec.
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub fallback_chain: Vec<String>,
97    /// Per-agent difficulty-routing override. Absent fields inherit the global
98    /// `models.routing`.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub routing: Option<crate::config::RoutingOverride>,
101    /// Per-agent Smart background-routing override. Absent fields inherit the
102    /// global `models.smart`; `None` means "follow the global setting".
103    /// Promoted out of `routing` — nesting it there meant overriding Smart
104    /// silently rewrote this agent's difficulty routing as a side effect.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub smart: Option<crate::config::SmartOverride>,
107    #[serde(default)]
108    pub mcp_servers: Vec<McpServerEntry>,
109    #[serde(default)]
110    pub skills: Vec<String>,
111    /// Skills installed via `mur skill install`. Distinct from `skills`
112    /// (which holds legacy per-agent paths from `mur agent skill add`).
113    /// Broadcast in the Agent Card alongside `skills`.
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub installed_skills: Vec<SkillCardEntry>,
116    /// Per-agent skill denylist (add-on Phase 1). Skill names that are
117    /// installed/visible to this agent but suppressed from injection.
118    /// Non-destructive: the skill's files/stats are untouched. Empty = all
119    /// visible skills enabled (back-compat: absent in old profiles).
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub disabled_skills: Vec<String>,
122
123    /// Per-agent MCP denylist (add-on Phase 1). `McpServerEntry` names not
124    /// spawned for this agent. Non-destructive: the entry + its pin stay in
125    /// the profile. Empty = all configured servers enabled.
126    #[serde(default, skip_serializing_if = "Vec::is_empty")]
127    pub disabled_mcp: Vec<String>,
128
129    /// Names of per-agent secrets the user handed this agent (murmur
130    /// `/secret`, `mur agent secret set`). NAMES ONLY — the values live in the
131    /// keychain under `mur-agent/<name>/<NAME>`. The list exists because the
132    /// keychain cannot be enumerated: the supervisor reads it pre-seal to know
133    /// which accounts to load. Empty = nothing to load (back-compat).
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub secrets: Vec<String>,
136    /// Plugin-groups imported by this agent (add-on Phase 2). Each is
137    /// self-contained (members installed per-agent). Absent/empty in
138    /// legacy profiles (back-compat).
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub addons: Vec<AddonRef>,
141    pub transport: TransportConfig,
142    pub communication: CommunicationConfig,
143    #[serde(default)]
144    pub capabilities: Vec<String>,
145    pub entitlements: Entitlements,
146    #[serde(default)]
147    pub notifications: NotificationsConfig,
148    pub retry: RetryConfig,
149    pub lifecycle: LifecycleConfig,
150    /// Cryptographic identity for cross-host A2A (P0a.5+). Default = empty
151    /// (legacy P0a profiles continue to load without this block).
152    #[serde(default)]
153    pub identity: IdentityConfig,
154    #[serde(default)]
155    pub file_transfer: FileTransferConfig,
156    #[serde(default)]
157    pub deployment: DeploymentConfig,
158    /// Companion subsystem (Phase 1.1+). Default = disabled (legacy profiles
159    /// continue to load without this block).
160    #[serde(default)]
161    pub companion: CompanionConfig,
162    /// Human-in-the-loop configuration (Phase 2). Default = disabled.
163    #[serde(default)]
164    pub hitl: HitlConfig,
165    /// Execution limits for this agent's own tasks (spec 2026-09-12 §3.1).
166    /// Absent → inherit. Replaces `hitl.max_iterations` / `hitl.max_tokens`,
167    /// which stay readable for the migration warning until the runtime
168    /// switch (step 4) stops applying them.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub limits: Option<crate::limits::Limits>,
171    /// Voice I/O configuration (D1). Default = disabled.
172    #[serde(default)]
173    pub voice: VoiceConfig,
174    /// A1: config-driven handler picker. Absent block = all defaults.
175    #[serde(default)]
176    pub hooks: crate::HooksConfig,
177    /// Pubkeys of bridges (and other LLM-less peers) this agent will accept
178    /// signed envelopes from. Empty = accept no bridge traffic. Default = empty.
179    #[serde(default)]
180    pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
181    pub created_at: String,
182    pub updated_at: String,
183    /// Hub companion visual identity (M-h3). Default = default-blob / Normal / Pending.
184    #[serde(default)]
185    pub appearance: AgentAppearance,
186    /// E6: Pattern federation — snapshot filter + outbox config.
187    #[serde(default)]
188    pub federation: FederationConfig,
189
190    /// A1: declarative UI action list — file_actions rendered as action
191    /// buttons in the pending-item selection UI. New top-level key; NOT
192    /// nested under `capabilities:`.
193    #[serde(default)]
194    pub file_actions: Vec<crate::action::FileAction>,
195
196    /// A2 + A3: action pipeline configuration (deletion safety + queue limits).
197    #[serde(default)]
198    pub action_pipeline: crate::action::ActionPipelineConfig,
199
200    /// External programs this artifact needs at runtime (portable-deps spec).
201    /// Absent → empty; resolved by `mur agent/fleet doctor` + `install-deps`.
202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
203    pub requires_programs: Vec<ProgramDep>,
204
205    /// Capability refs installed into this agent (Pack S3). Absent → empty;
206    /// resolved against the local capability registry / bundle store.
207    #[serde(default, skip_serializing_if = "Vec::is_empty")]
208    pub requires_capabilities: Vec<String>,
209}
210
211fn default_algorithm() -> String {
212    "ed25519".into()
213}
214
215/// Algorithms the runtime can generate + verify.
216pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
217
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
219pub struct IdentityConfig {
220    /// Multibase-encoded Ed25519 public key (base58btc, `z` prefix).
221    /// Empty string for legacy P0a profiles; filled on P0a.5 `mur agent create`.
222    #[serde(default)]
223    pub pubkey: String,
224    /// Free-form owner identity (email / SSO sub). None for legacy profiles.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub owner: Option<String>,
227
228    // P0a.6 rekey extensions (all #[serde(default)] — back-compat)
229    /// Cryptographic algorithm for this key. Defaults to "ed25519".
230    #[serde(default = "default_algorithm")]
231    pub algorithm: String,
232    /// Monotonic version counter; 0 = initial create, increments on each rotation.
233    #[serde(default)]
234    pub key_version: u32,
235    /// RFC3339 timestamp of when this key was created.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub created_at_key: Option<String>,
238    /// Previous public key (before most recent rotation). None if not rotated yet.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub previous_pubkey: Option<String>,
241    /// Version of the previous key. None if not rotated yet.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub previous_key_version: Option<u32>,
244    /// RFC3339 timestamp when grace period expires and old key is fully retired.
245    /// Only set during rotation; cleared once grace period ends.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub grace_expires_at: Option<String>,
248    /// RFC3339 timestamp of the most recent key rotation (normal, not emergency).
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub rotated_at: Option<String>,
251    /// RFC3339 timestamp of emergency key rotation (set only if emergency rekey occurred).
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub emergency_rekey_at: Option<String>,
254}
255
256impl Default for IdentityConfig {
257    fn default() -> Self {
258        Self {
259            pubkey: String::new(),
260            owner: None,
261            algorithm: default_algorithm(),
262            key_version: 0,
263            created_at_key: None,
264            previous_pubkey: None,
265            previous_key_version: None,
266            grace_expires_at: None,
267            rotated_at: None,
268            emergency_rekey_at: None,
269        }
270    }
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
274pub struct Persona {
275    pub category: PersonaCategory,
276    pub description: String,
277    pub traits: PersonaTraits,
278}
279
280#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
281#[serde(rename_all = "lowercase")]
282pub enum PersonaCategory {
283    Research,
284    Automation,
285    Monitor,
286    Notify,
287    Commerce,
288    Custom,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
292pub struct PersonaTraits {
293    pub tone: String,
294    pub risk: String,
295    pub verbosity: String,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
299pub struct ModelConfig {
300    pub provider: String,
301    pub name: String,
302    #[serde(default)]
303    pub params: BTreeMap<String, serde_yaml_ng::Value>,
304}
305
306fn default_true() -> bool {
307    true
308}
309
310impl AgentProfile {
311    /// Minimal valid profile for tests — no voice, no MCP, no skills.
312    ///
313    /// Available in all compilation modes so integration tests in
314    /// dependent crates can call it (unlike `#[cfg(test)]` items which
315    /// are invisible to downstream test binaries).
316    #[doc(hidden)]
317    pub fn default_for_tests() -> Self {
318        serde_yaml_ng::from_str(include_str!("../../tests/fixtures/minimal_profile.yaml"))
319            .expect("minimal profile fixture")
320    }
321
322    /// This agent's Smart override, wherever it lives: the promoted `smart`
323    /// field, else the legacy `routing.smart` nesting that older profiles and
324    /// exported `.muragent` bundles still carry. `None` = follow the global
325    /// setting.
326    ///
327    /// Every reader goes through here. A surface that checked only the
328    /// promoted field would report "follows global" for an agent whose legacy
329    /// override is actually in force — one fact, two answers.
330    pub fn smart_override(&self) -> Option<&crate::config::SmartOverride> {
331        self.smart
332            .as_ref()
333            .or_else(|| self.routing.as_ref().and_then(|r| r.smart.as_ref()))
334    }
335
336    /// This agent's effective Smart config: the global values with the agent's
337    /// override layered on.
338    pub fn effective_smart(
339        &self,
340        cfg: &crate::config::ModelSwitchConfig,
341    ) -> crate::config::SmartConfig {
342        cfg.smart.merged(self.smart_override())
343    }
344
345    /// This agent's effective difficulty-routing config.
346    pub fn effective_routing(
347        &self,
348        cfg: &crate::config::ModelSwitchConfig,
349    ) -> crate::config::RoutingConfig {
350        cfg.routing.merged(self.routing.as_ref())
351    }
352
353    /// Load an agent's profile from `<mur_home>/agents/<name>/profile.yaml`.
354    ///
355    /// Canonical read-path counterpart to the atomic-write path used by
356    /// `mur agent create`/`mur agent mcp add` (`write_atomic` in
357    /// `mur-core::cmd::agent`) — callers that already have `mur_home` in
358    /// hand (e.g. provisioning flows, tests) can load a profile without
359    /// going through the `MUR_HOME`-env-var-based `resolve_mur_home`.
360    pub fn load(mur_home: &std::path::Path, name: &str) -> anyhow::Result<Self> {
361        let path = mur_home.join("agents").join(name).join("profile.yaml");
362        let yaml = std::fs::read_to_string(&path)
363            .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
364        serde_yaml_ng::from_str(&yaml).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))
365    }
366
367    /// The imported add-on group a skill/mcp/command name belongs to.
368    pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
369        self.addons.iter().find(|g| {
370            g.skills.iter().any(|n| n == name)
371                || g.mcp.iter().any(|n| n == name)
372                || g.commands.iter().any(|n| n == name)
373        })
374    }
375
376    /// Whether `skill_name` is enabled (§3.3): not denied AND, if it
377    /// belongs to an imported group, that group is enabled.
378    pub fn skill_enabled(&self, skill_name: &str) -> bool {
379        name_enabled(&self.disabled_skills, skill_name)
380            && self.group_of(skill_name).is_none_or(|g| g.enabled)
381    }
382
383    /// Whether MCP server `server_id` is enabled (§3.3).
384    pub fn mcp_enabled(&self, server_id: &str) -> bool {
385        name_enabled(&self.disabled_mcp, server_id)
386            && self.group_of(server_id).is_none_or(|g| g.enabled)
387    }
388
389    /// Toggle a skill for this agent without uninstalling it.
390    pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
391        set_denylist(&mut self.disabled_skills, skill_name, enabled);
392    }
393
394    /// Toggle an MCP server for this agent without removing it.
395    pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
396        set_denylist(&mut self.disabled_mcp, server_id, enabled);
397    }
398
399    /// Toggle an imported plugin-group as a unit. Returns false if no
400    /// add-on has that id.
401    pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
402        match self.addons.iter_mut().find(|g| g.id == addon_id) {
403            Some(g) => {
404                g.enabled = enabled;
405                true
406            }
407            None => false,
408        }
409    }
410
411    /// Emergency kill-switch (§7): clears every add-on group's `enabled` flag.
412    /// Members are already forced off by the group AND-gate in `skill_enabled` /
413    /// `mcp_enabled`, so no denylist push is needed — and avoiding it means
414    /// `set_addon_enabled(id, true)` fully restores the group without leftover
415    /// per-member denials.
416    pub fn disable_all_addons(&mut self) {
417        for g in &mut self.addons {
418            g.enabled = false;
419        }
420    }
421
422    /// This agent's MCP servers minus any disabled for it.
423    pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
424        self.mcp_servers
425            .iter()
426            .filter(|m| self.mcp_enabled(&m.name))
427            .cloned()
428            .collect()
429    }
430}
431
432#[cfg(test)]
433mod model_ref_tests {
434    use super::*;
435
436    #[test]
437    fn legacy_profile_without_model_ref_still_parses() {
438        let yaml = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
439        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
440        assert!(
441            p.model_ref.is_none(),
442            "legacy profile must not have model_ref"
443        );
444    }
445
446    #[test]
447    fn round_trip_with_model_ref_preserves_field() {
448        let yaml = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
449        let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
450        p.model_ref = Some("anthropic_opus_4_7".into());
451        let s = serde_yaml_ng::to_string(&p).unwrap();
452        assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
453        let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
454        assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
455    }
456
457    #[test]
458    fn per_agent_fallback_and_routing_optional_and_legacy_safe() {
459        // Load fixture (no fallback_chain / routing) — legacy safe.
460        let yaml = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
461        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
462        assert!(
463            p.fallback_chain.is_empty(),
464            "legacy profile must have empty fallback_chain"
465        );
466        assert!(
467            p.routing.is_none(),
468            "legacy profile must have no routing override"
469        );
470
471        // Round-trip with fallback_chain and routing.
472        let mut p = p.clone();
473        p.fallback_chain = vec!["claude_opus".into(), "claude_sonnet".into()];
474        p.routing = Some(crate::config::RoutingOverride {
475            enabled: Some(true),
476            ..Default::default()
477        });
478        let s = serde_yaml_ng::to_string(&p).unwrap();
479        assert!(
480            s.contains("fallback_chain:"),
481            "yaml must contain fallback_chain"
482        );
483        assert!(s.contains("routing:"), "yaml must contain routing");
484        let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
485        assert_eq!(
486            p2.fallback_chain,
487            vec!["claude_opus", "claude_sonnet"],
488            "fallback_chain must round-trip"
489        );
490        assert_eq!(
491            p2.routing.as_ref().unwrap().enabled,
492            Some(true),
493            "routing.enabled must round-trip"
494        );
495    }
496
497    #[test]
498    fn effective_smart_prefers_the_promoted_field_then_the_legacy_nesting() {
499        use crate::config::{ModelSwitchConfig, SmartConfig, SmartOverride};
500        let cfg = ModelSwitchConfig {
501            smart: SmartConfig {
502                enabled: false,
503                cheap: Some("g".into()),
504                max_escalations: 2,
505            },
506            ..Default::default()
507        };
508        // No override at all → the global values, untouched.
509        let p = AgentProfile::default_for_tests();
510        assert_eq!(p.effective_smart(&cfg), cfg.smart);
511
512        // Legacy profiles carry the override nested under `routing`.
513        let mut legacy = AgentProfile::default_for_tests();
514        legacy.routing = Some(crate::config::RoutingOverride {
515            smart: Some(SmartOverride {
516                enabled: Some(true),
517                ..Default::default()
518            }),
519            ..Default::default()
520        });
521        assert!(
522            legacy.effective_smart(&cfg).enabled,
523            "legacy nesting is read"
524        );
525        assert_eq!(
526            legacy.effective_smart(&cfg).cheap.as_deref(),
527            Some("g"),
528            "unset fields still inherit"
529        );
530
531        // The promoted field wins when both are present.
532        let mut both = legacy.clone();
533        both.smart = Some(SmartOverride {
534            enabled: Some(false),
535            ..Default::default()
536        });
537        assert!(!both.effective_smart(&cfg).enabled);
538    }
539}
540
541#[cfg(test)]
542mod skill_card_tests {
543    use super::*;
544
545    #[test]
546    fn installed_skills_default_to_empty_when_absent() {
547        let yaml = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
548        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
549        assert!(p.installed_skills.is_empty());
550    }
551
552    #[test]
553    fn installed_skills_roundtrip_preserves_entries() {
554        let base = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
555        let yaml = format!(
556            "{base}installed_skills:\n  - name: s1\n    version: 1.0.0\n    publisher: human:d\n    description: desc\n    category: workflow\n    tags: [web]\n    triggers:\n      - type: command\n        pattern: /find\n    abstract: does things\n    transfer_chain:\n      - agent://alice\n"
557        );
558        let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
559        assert_eq!(p.installed_skills.len(), 1);
560        assert_eq!(p.installed_skills[0].name, "s1");
561        assert_eq!(p.installed_skills[0].abstract_text, "does things");
562        assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
563
564        let out = serde_yaml_ng::to_string(&p).unwrap();
565        assert!(out.contains("abstract: does things"));
566        assert!(out.contains("pattern: /find"));
567
568        let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
569        assert_eq!(p.installed_skills, back.installed_skills);
570    }
571
572    #[test]
573    fn installed_skills_minimal_entry_serializes_compactly() {
574        // A name-only entry must NOT emit empty string fields.
575        let entry = SkillCardEntry {
576            name: "minimal".into(),
577            ..Default::default()
578        };
579        let yaml = serde_yaml_ng::to_string(&entry).unwrap();
580        assert!(yaml.contains("name: minimal"));
581        assert!(
582            !yaml.contains("version:"),
583            "empty version must be skipped: {yaml}"
584        );
585        assert!(
586            !yaml.contains("publisher:"),
587            "empty publisher must be skipped: {yaml}"
588        );
589        assert!(
590            !yaml.contains("abstract:"),
591            "empty abstract must be skipped: {yaml}"
592        );
593    }
594}
595
596#[cfg(test)]
597mod secrets_field_tests {
598    /// The list is NAMES only and must stay absent from the YAML when empty:
599    /// every existing profile on disk is rewritten by unrelated edits, and a
600    /// new always-present key would churn all of them.
601    #[test]
602    fn secrets_names_round_trip_and_are_absent_when_empty() {
603        let mut p = crate::agent::AgentProfile::default_for_tests();
604        let yaml = serde_yaml::to_string(&p).unwrap();
605        assert!(
606            !yaml.contains("secrets:"),
607            "empty list must not be written: {yaml}"
608        );
609        p.secrets = vec!["GITEA_TOKEN".into()];
610        let yaml = serde_yaml::to_string(&p).unwrap();
611        let back: crate::agent::AgentProfile = serde_yaml::from_str(&yaml).unwrap();
612        assert_eq!(back.secrets, vec!["GITEA_TOKEN".to_string()]);
613    }
614}