Skip to main content

mur_common/
agent.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 serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8/// Skill metadata broadcast in the Agent Card (Layer 1 + Layer 2).
9///
10/// Populated by `mur skill install` (registry or agent:// URL). Distinct from
11/// `AgentProfile.skills`, which is the legacy per-agent-path list managed by
12/// `mur agent skill add`.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
14pub struct SkillCardEntry {
15    pub name: String,
16    #[serde(default, skip_serializing_if = "String::is_empty")]
17    pub version: String,
18    #[serde(default, skip_serializing_if = "String::is_empty")]
19    pub publisher: String,
20    #[serde(default, skip_serializing_if = "String::is_empty")]
21    pub description: String,
22    #[serde(default, skip_serializing_if = "String::is_empty")]
23    pub category: String,
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub tags: Vec<String>,
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub triggers: Vec<SkillCardTrigger>,
28    /// Layer 2 abstract — injected at session start (~200 tokens).
29    /// On-disk YAML key is `abstract` (a Rust reserved word).
30    #[serde(default, skip_serializing_if = "String::is_empty", rename = "abstract")]
31    pub abstract_text: String,
32    /// Provenance chain copied from the installed manifest. Empty for
33    /// registry-installed skills.
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub transfer_chain: Vec<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
39pub struct SkillCardTrigger {
40    #[serde(rename = "type")]
41    pub kind: String,
42    #[serde(default, skip_serializing_if = "String::is_empty")]
43    pub pattern: String,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct AgentProfile {
48    pub schema: u32,
49    pub id: String, // UUIDv7
50    pub name: String,
51    pub display_name: String,
52    pub version: String,
53    pub persona: Persona,
54    pub sys_prompt_file: String,
55    pub model: ModelConfig,
56    /// Optional pointer into ~/.mur/models.yaml. When set, the runtime
57    /// prefers the registry entry over the inline `model:` block.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub model_ref: Option<String>,
60    #[serde(default)]
61    pub mcp_servers: Vec<McpServerEntry>,
62    #[serde(default)]
63    pub skills: Vec<String>,
64    /// Skills installed via `mur skill install`. Distinct from `skills`
65    /// (which holds legacy per-agent paths from `mur agent skill add`).
66    /// Broadcast in the Agent Card alongside `skills`.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub installed_skills: Vec<SkillCardEntry>,
69    pub transport: TransportConfig,
70    pub communication: CommunicationConfig,
71    #[serde(default)]
72    pub capabilities: Vec<String>,
73    pub entitlements: Entitlements,
74    #[serde(default)]
75    pub notifications: NotificationsConfig,
76    pub retry: RetryConfig,
77    pub lifecycle: LifecycleConfig,
78    /// Cryptographic identity for cross-host A2A (P0a.5+). Default = empty
79    /// (legacy P0a profiles continue to load without this block).
80    #[serde(default)]
81    pub identity: IdentityConfig,
82    #[serde(default)]
83    pub file_transfer: FileTransferConfig,
84    #[serde(default)]
85    pub deployment: DeploymentConfig,
86    /// Companion subsystem (Phase 1.1+). Default = disabled (legacy profiles
87    /// continue to load without this block).
88    #[serde(default)]
89    pub companion: CompanionConfig,
90    /// Voice I/O configuration (D1). Default = disabled.
91    #[serde(default)]
92    pub voice: VoiceConfig,
93    /// A1: config-driven handler picker. Absent block = all defaults.
94    #[serde(default)]
95    pub hooks: crate::HooksConfig,
96    /// Pubkeys of bridges (and other LLM-less peers) this agent will accept
97    /// signed envelopes from. Empty = accept no bridge traffic. Default = empty.
98    #[serde(default)]
99    pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
100    pub created_at: String,
101    pub updated_at: String,
102    /// Hub companion visual identity (M-h3). Default = default-blob / Normal / Pending.
103    #[serde(default)]
104    pub appearance: AgentAppearance,
105    /// E6: Pattern federation — snapshot filter + outbox config.
106    #[serde(default)]
107    pub federation: FederationConfig,
108
109    /// A1: declarative UI action list — file_actions rendered as action
110    /// buttons in the pending-item selection UI. New top-level key; NOT
111    /// nested under `capabilities:`.
112    #[serde(default)]
113    pub file_actions: Vec<crate::action::FileAction>,
114
115    /// A2 + A3: action pipeline configuration (deletion safety + queue limits).
116    #[serde(default)]
117    pub action_pipeline: crate::action::ActionPipelineConfig,
118}
119
120fn default_algorithm() -> String {
121    "ed25519".into()
122}
123
124/// Algorithms the runtime can generate + verify.
125pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
126
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
128pub struct IdentityConfig {
129    /// Multibase-encoded Ed25519 public key (base58btc, `z` prefix).
130    /// Empty string for legacy P0a profiles; filled on P0a.5 `mur agent create`.
131    #[serde(default)]
132    pub pubkey: String,
133    /// Free-form owner identity (email / SSO sub). None for legacy profiles.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub owner: Option<String>,
136
137    // P0a.6 rekey extensions (all #[serde(default)] — back-compat)
138    /// Cryptographic algorithm for this key. Defaults to "ed25519".
139    #[serde(default = "default_algorithm")]
140    pub algorithm: String,
141    /// Monotonic version counter; 0 = initial create, increments on each rotation.
142    #[serde(default)]
143    pub key_version: u32,
144    /// RFC3339 timestamp of when this key was created.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub created_at_key: Option<String>,
147    /// Previous public key (before most recent rotation). None if not rotated yet.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub previous_pubkey: Option<String>,
150    /// Version of the previous key. None if not rotated yet.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub previous_key_version: Option<u32>,
153    /// RFC3339 timestamp when grace period expires and old key is fully retired.
154    /// Only set during rotation; cleared once grace period ends.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub grace_expires_at: Option<String>,
157    /// RFC3339 timestamp of the most recent key rotation (normal, not emergency).
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub rotated_at: Option<String>,
160    /// RFC3339 timestamp of emergency key rotation (set only if emergency rekey occurred).
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub emergency_rekey_at: Option<String>,
163}
164
165impl Default for IdentityConfig {
166    fn default() -> Self {
167        Self {
168            pubkey: String::new(),
169            owner: None,
170            algorithm: default_algorithm(),
171            key_version: 0,
172            created_at_key: None,
173            previous_pubkey: None,
174            previous_key_version: None,
175            grace_expires_at: None,
176            rotated_at: None,
177            emergency_rekey_at: None,
178        }
179    }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
183pub struct Persona {
184    pub category: PersonaCategory,
185    pub description: String,
186    pub traits: PersonaTraits,
187}
188
189#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
190#[serde(rename_all = "lowercase")]
191pub enum PersonaCategory {
192    Research,
193    Automation,
194    Monitor,
195    Notify,
196    Commerce,
197    Custom,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
201pub struct PersonaTraits {
202    pub tone: String,
203    pub risk: String,
204    pub verbosity: String,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
208pub struct ModelConfig {
209    pub provider: String,
210    pub name: String,
211    #[serde(default)]
212    pub params: BTreeMap<String, serde_yaml_ng::Value>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
216pub struct McpServerEntry {
217    pub name: String,
218    pub command: String,
219    #[serde(default)]
220    pub args: Vec<String>,
221
222    /// SHA-256 (hex, lowercase) of the binary at `command`'s resolved
223    /// path, captured at install time. `None` means the entry was
224    /// added before B0 M9.1 (back-compat) and rule-6 enforcement is
225    /// not applied — the supervisor will warn but not block.
226    /// (B0 rule 6 / M9.1)
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub binary_sha256: Option<String>,
229
230    /// SHA-256 (hex, lowercase) of the canonical-JSON of the MCP's
231    /// `tools/list` response, captured at install time. `None` means
232    /// the install path skipped the description probe (e.g. the MCP
233    /// uses a non-stdio transport or the binary couldn't be reached)
234    /// or the entry pre-dates M9. (B0 rule 6 / M9.1)
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub description_hash: Option<String>,
237
238    /// Display-only publisher metadata captured at install time so
239    /// the user can recall what they consented to. `None` for older
240    /// entries. (B0 rule 6 / M9.1)
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub publisher: Option<McpPublisherInfo>,
243
244    /// RFC3339 timestamp of when the entry was added or last
245    /// re-approved by the user via `mur agent mcp pin`. Used by the
246    /// rug-pull dialog UX. `None` for older entries. (B0 rule 6 / M9.1)
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
249}
250
251/// Display-only publisher metadata captured at install time. None of
252/// the fields are validated against any external authority — they're
253/// shown to the user during the install confirm prompt and reproduced
254/// in `mur agent mcp inspect` output so the user can audit who they
255/// thought they were trusting. (B0 rule 6 / M9.1)
256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
257pub struct McpPublisherInfo {
258    /// Free-form publisher identifier — e.g. `"Anthropic"`,
259    /// `"@github-user-alice"`, or whatever `serverInfo.name` returned.
260    pub name: String,
261
262    /// Optional homepage / docs URL. Best-effort: extracted from the
263    /// MCP's `serverInfo.metadata.homepage` or registry entry when
264    /// available; otherwise left unset.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub homepage: Option<String>,
267
268    /// Optional registry coordinate — e.g. `"@anthropic-mcp/weather@1.2.3"`.
269    /// Used purely for display; not consumed by any verification path.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub registry_id: Option<String>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
275pub struct TransportConfig {
276    pub stdio: bool,
277    pub socket: SocketTransportConfig,
278    #[serde(default)]
279    pub tcp: TcpTransportConfig,
280    /// Track C5 — HTTP webhook receiver. Default off; enabling
281    /// requires an HMAC secret in the OS keychain (`SecretRef`).
282    /// See `docs/superpowers/specs/2026-05-05-mur-agent-c5-webhook-design.md`.
283    #[serde(default)]
284    pub webhook: WebhookTransportConfig,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
288pub struct TcpTransportConfig {
289    #[serde(default)]
290    pub enabled: bool,
291    #[serde(default)]
292    pub bind: String,
293    #[serde(default)]
294    pub noise: NoiseConfig,
295}
296
297/// HTTP webhook receiver — Track C5.
298///
299/// External systems POST `SharePayload`-shaped JSON to
300/// `http://<bind>:<port>/agents/<slug>/webhook` with an
301/// `X-Mur-Signature: sha256=<hex>` header carrying an HMAC-SHA256
302/// over the raw body. The HMAC secret is stored in the OS keychain
303/// via `SecretRef` (same pattern as Telegram bot tokens in C2);
304/// `hmac_secret_ref` is the `service:account` lookup key.
305///
306/// `bind` defaults to `127.0.0.1` so a fresh enable doesn't
307/// inadvertently expose the agent to the local network. Users who
308/// want VPN / Tailscale reachability override to `0.0.0.0` or the
309/// VPN interface address explicitly.
310#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
311pub struct WebhookTransportConfig {
312    #[serde(default)]
313    pub enabled: bool,
314    #[serde(default = "default_webhook_bind")]
315    pub bind: String,
316    #[serde(default = "default_webhook_port")]
317    pub port: u16,
318    /// `service:account` key into the OS keychain. Empty string
319    /// when `enabled = false`; required (and validated) at startup
320    /// when enabled.
321    #[serde(default)]
322    pub hmac_secret_ref: String,
323}
324
325fn default_webhook_bind() -> String {
326    "127.0.0.1".to_string()
327}
328
329fn default_webhook_port() -> u16 {
330    6789
331}
332
333impl Default for WebhookTransportConfig {
334    fn default() -> Self {
335        Self {
336            enabled: false,
337            bind: default_webhook_bind(),
338            port: default_webhook_port(),
339            hmac_secret_ref: String::new(),
340        }
341    }
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
345pub struct NoiseConfig {
346    pub pattern: String,
347}
348
349impl Default for NoiseConfig {
350    fn default() -> Self {
351        Self {
352            pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
353        }
354    }
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
358pub struct SocketTransportConfig {
359    pub enabled: bool,
360    pub bind: String, // "unix:///path" or "tcp://host:port" (P0b)
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub auth: Option<AuthConfig>,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
366pub struct AuthConfig {
367    pub scheme: String,
368    pub token_file: String,
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
372pub struct CommunicationConfig {
373    #[serde(default = "default_accepts_all")]
374    pub accepts_from: Vec<String>,
375    #[serde(default)]
376    pub sends_to: Vec<String>,
377}
378fn default_accepts_all() -> Vec<String> {
379    vec!["*".to_string()]
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
383pub struct Entitlements {
384    pub network: NetworkEntitlement,
385    pub filesystem: FilesystemEntitlement,
386    pub processes: ProcessesEntitlement,
387    #[serde(default)]
388    pub syscalls: SyscallsEntitlement,
389    #[serde(default)]
390    pub limits: LimitsEntitlement,
391    /// LLM call permission. Default = Allowed (back-compat). Bridges set to Off
392    /// so the supervisor refuses to construct an LLM client.
393    #[serde(default)]
394    pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
395    /// When `true` (the default), a sandbox apply failure is fatal: the agent
396    /// refuses to start rather than running advisory-only (unconfined).
397    /// Set to `false` only for development or trusted-workstation agents that
398    /// intentionally run without kernel sandbox enforcement.
399    #[serde(default = "default_true")]
400    pub fail_closed_on_sandbox_error: bool,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
404pub struct NetworkEntitlement {
405    pub inbound: InboundNetwork,
406    pub outbound: OutboundNetwork,
407}
408
409#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
410pub struct InboundNetwork {
411    #[serde(default)]
412    pub ports: Vec<u16>,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
416pub struct OutboundNetwork {
417    pub mode: NetworkOutboundMode,
418    #[serde(default)]
419    pub allow_hosts: Vec<String>,
420    #[serde(default = "default_protocols")]
421    pub protocols: Vec<String>,
422    #[serde(default)]
423    pub resolve_dns: ResolveDnsConfig,
424}
425fn default_protocols() -> Vec<String> {
426    vec!["tcp".to_string()]
427}
428
429#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
430#[serde(rename_all = "lowercase")]
431pub enum NetworkOutboundMode {
432    Unrestricted,
433    Restricted,
434    Off,
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
438pub struct ResolveDnsConfig {
439    #[serde(default = "default_dns_mode")]
440    pub mode: String,
441    #[serde(default)]
442    pub servers: Vec<String>,
443}
444impl Default for ResolveDnsConfig {
445    fn default() -> Self {
446        Self {
447            mode: default_dns_mode(),
448            servers: vec![],
449        }
450    }
451}
452fn default_dns_mode() -> String {
453    "system".to_string()
454}
455
456#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
457pub struct FilesystemEntitlement {
458    #[serde(default)]
459    pub read: Vec<String>,
460    #[serde(default)]
461    pub write: Vec<String>,
462    #[serde(default)]
463    pub deny: Vec<String>,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
467pub struct ProcessesEntitlement {
468    pub spawn: SpawnEntitlement,
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
472pub struct SpawnEntitlement {
473    pub mode: SpawnMode,
474    #[serde(default)]
475    pub allowed: Vec<String>,
476}
477
478#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
479#[serde(rename_all = "lowercase")]
480pub enum SpawnMode {
481    Allowlist,
482    Any,
483    None,
484}
485
486#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
487pub struct SyscallsEntitlement {
488    #[serde(default = "default_syscalls_mode")]
489    pub mode: String,
490    #[serde(default)]
491    pub extra_deny: Vec<String>,
492}
493fn default_syscalls_mode() -> String {
494    "default".to_string()
495}
496
497#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
498pub struct LimitsEntitlement {
499    #[serde(default)]
500    pub cpu_seconds: Option<u64>,
501    #[serde(default = "default_memory_mb")]
502    pub memory_mb: u64,
503    #[serde(default = "default_fds")]
504    pub file_descriptors: u32,
505    #[serde(default = "default_procs")]
506    pub processes: u32,
507}
508fn default_memory_mb() -> u64 {
509    512
510}
511fn default_fds() -> u32 {
512    1024
513}
514fn default_procs() -> u32 {
515    32
516}
517
518#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
519pub struct NotificationsConfig {
520    #[serde(default)]
521    pub on_task_complete: Vec<NotificationTarget>,
522    #[serde(default)]
523    pub on_error: Vec<NotificationTarget>,
524    #[serde(default)]
525    pub on_shutdown: Vec<NotificationTarget>,
526}
527
528#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
529#[serde(tag = "target", rename_all = "lowercase")]
530pub enum NotificationTarget {
531    Agent {
532        name: String,
533    },
534    Commander,
535    Email {
536        address: String,
537        #[serde(default)]
538        smtp_config_file: Option<String>,
539    },
540    Slack {
541        #[serde(default)]
542        channel: Option<String>,
543        #[serde(default)]
544        webhook_url_env: Option<String>,
545    },
546    Webpush {
547        url: String,
548    },
549    Webhook {
550        url: String,
551        #[serde(default = "default_post")]
552        method: String,
553        #[serde(default)]
554        auth: Option<String>,
555    },
556}
557fn default_post() -> String {
558    "POST".to_string()
559}
560
561#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
562pub struct RetryConfig {
563    pub llm: RetryPolicy,
564    pub tool: RetryPolicy,
565}
566
567#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
568pub struct RetryPolicy {
569    pub max_retries: u32,
570    pub backoff: BackoffStrategy,
571    pub initial_delay_ms: u64,
572    #[serde(default)]
573    pub max_delay_ms: Option<u64>,
574    #[serde(default)]
575    pub retry_on: Vec<String>,
576}
577
578#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
579#[serde(rename_all = "lowercase")]
580pub enum BackoffStrategy {
581    Linear,
582    Exponential,
583    Fixed,
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
587pub struct LifecycleConfig {
588    pub restart: RestartPolicy,
589    #[serde(default = "default_max_restarts")]
590    pub max_restarts: u32,
591    #[serde(default = "default_window")]
592    pub restart_window_secs: u64,
593    #[serde(default = "default_stop_timeout")]
594    pub stop_timeout_secs: u64,
595    #[serde(default = "default_mcp_required")]
596    pub mcp_required: bool,
597    #[serde(default)]
598    pub execution: ExecutionMode,
599    #[serde(default)]
600    pub schedule: Vec<ScheduleEntry>,
601    #[serde(default)]
602    pub idle_triggers: Vec<IdleTrigger>,
603}
604fn default_max_restarts() -> u32 {
605    3
606}
607fn default_window() -> u64 {
608    600
609}
610fn default_stop_timeout() -> u64 {
611    15
612}
613fn default_mcp_required() -> bool {
614    true
615}
616
617#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
618#[serde(rename_all = "snake_case")]
619pub enum RestartPolicy {
620    Never,
621    OnFailure,
622    Always,
623}
624
625#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
626#[serde(rename_all = "snake_case")]
627pub enum ExecutionMode {
628    #[default]
629    Daemon,
630    OnDemand,
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
634pub struct ScheduleEntry {
635    pub cron: String,
636    pub message: String,
637    #[serde(default, skip_serializing_if = "Option::is_none")]
638    pub sends_to: Option<String>,
639}
640
641#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
642pub struct IdleTrigger {
643    /// Idle threshold in seconds. Fires when (now - last_activity) >= after_secs.
644    pub after_secs: u64,
645    /// Message body injected into the task runner when this trigger fires.
646    pub message: String,
647    /// Optional A2A peer to route the resulting reply to. None means the agent itself.
648    #[serde(default, skip_serializing_if = "Option::is_none")]
649    pub sends_to: Option<String>,
650    /// Per-trigger refire cooldown in seconds. Prevents tight loops when the
651    /// idle threshold is short and the runner finishes quickly. Default 600.
652    #[serde(default = "default_idle_cooldown")]
653    pub cooldown_secs: u64,
654    /// When true, suppress firing during the agent's quiet-hours window.
655    /// Default true — idle pings should not wake the user at 3 a.m.
656    #[serde(default = "default_true")]
657    pub respect_quiet_hours: bool,
658}
659
660fn default_idle_cooldown() -> u64 {
661    600
662}
663fn default_true() -> bool {
664    true
665}
666
667#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
668pub struct FileTransferConfig {
669    #[serde(default = "default_accept_max")]
670    pub accept_incoming_file_max_bytes: u64,
671    #[serde(default = "default_accept_total")]
672    pub accept_incoming_total_per_hour: u64,
673    #[serde(default = "default_approval_threshold")]
674    pub require_approval_above_bytes: u64,
675    #[serde(default = "default_reject_paths")]
676    pub reject_paths: Vec<String>,
677    #[serde(default = "default_allowed_mime")]
678    pub allowed_mime_types: Vec<String>,
679}
680
681impl Default for FileTransferConfig {
682    fn default() -> Self {
683        Self {
684            accept_incoming_file_max_bytes: default_accept_max(),
685            accept_incoming_total_per_hour: default_accept_total(),
686            require_approval_above_bytes: default_approval_threshold(),
687            reject_paths: default_reject_paths(),
688            allowed_mime_types: default_allowed_mime(),
689        }
690    }
691}
692
693fn default_accept_max() -> u64 {
694    10_485_760
695}
696fn default_accept_total() -> u64 {
697    104_857_600
698}
699fn default_approval_threshold() -> u64 {
700    10_485_760
701}
702fn default_reject_paths() -> Vec<String> {
703    vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
704}
705fn default_allowed_mime() -> Vec<String> {
706    vec!["*".into()]
707}
708
709#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
710#[serde(rename_all = "snake_case")]
711pub enum DeploymentType {
712    #[default]
713    Laptop,
714    Vm,
715    Docker,
716    K8s,
717    Lambda,
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
721pub struct DeploymentConfig {
722    #[serde(rename = "type", default)]
723    pub deployment_type: DeploymentType,
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub region: Option<String>,
726    #[serde(default = "default_env")]
727    pub environment: Option<String>,
728}
729
730impl Default for DeploymentConfig {
731    fn default() -> Self {
732        Self {
733            deployment_type: DeploymentType::default(),
734            region: None,
735            environment: default_env(),
736        }
737    }
738}
739
740fn default_env() -> Option<String> {
741    Some("dev".into())
742}
743
744#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
745pub struct LockFile {
746    pub schema: u32,
747    pub uuid: String,
748    pub name: String,
749    pub pid: u32,
750    pub ppid: u32,
751    pub started_at: String,
752    pub binary_version: String,
753    pub transports: LockTransports,
754    pub card_digest: String,
755    pub capabilities: Vec<String>,
756}
757
758#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
759pub struct LockTransports {
760    pub stdio: bool,
761    #[serde(default)]
762    pub unix_socket: Option<String>,
763    #[serde(default)]
764    pub tcp: Option<String>,
765    /// C5 / M5.3 — webhook listener URL (e.g. `http://127.0.0.1:6789`).
766    /// Populated by the supervisor when `transport.webhook.enabled =
767    /// true` so peers and the commander can discover the live
768    /// endpoint without re-reading `profile.yaml`.
769    #[serde(default)]
770    pub webhook: Option<String>,
771}
772
773// ──────────────────────────────────────────────────────────────────────────
774// Voice I/O configuration (D1 — Kokoro 82M TTS + whisper.cpp STT)
775// ──────────────────────────────────────────────────────────────────────────
776
777/// Kokoro 82M voice identity. Maps to the per-voice style vector
778/// embedded in the Kokoro ONNX model.
779#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
780#[serde(rename_all = "snake_case")]
781pub enum VoiceId {
782    /// Default: Kokoro af_heart voice.
783    #[default]
784    AfHeart,
785    AfBella,
786    AfNicole,
787    AmAdam,
788    AmMichael,
789}
790
791impl VoiceId {
792    /// Index into the Kokoro voices.bin style matrix (row index).
793    pub fn style_index(&self) -> usize {
794        match self {
795            VoiceId::AfHeart => 0,
796            VoiceId::AfBella => 1,
797            VoiceId::AfNicole => 2,
798            VoiceId::AmAdam => 3,
799            VoiceId::AmMichael => 4,
800        }
801    }
802
803    /// Canonical lowercase string representation (matches `FromStr` inputs).
804    pub fn as_str(&self) -> &'static str {
805        match self {
806            VoiceId::AfHeart => "af_heart",
807            VoiceId::AfBella => "af_bella",
808            VoiceId::AfNicole => "af_nicole",
809            VoiceId::AmAdam => "am_adam",
810            VoiceId::AmMichael => "am_michael",
811        }
812    }
813}
814
815impl std::str::FromStr for VoiceId {
816    type Err = anyhow::Error;
817
818    fn from_str(s: &str) -> anyhow::Result<Self> {
819        match s {
820            "af_heart" => Ok(VoiceId::AfHeart),
821            "af_bella" => Ok(VoiceId::AfBella),
822            "af_nicole" => Ok(VoiceId::AfNicole),
823            "am_adam" => Ok(VoiceId::AmAdam),
824            "am_michael" => Ok(VoiceId::AmMichael),
825            other => anyhow::bail!(
826                "unknown voice ID '{other}' \
827                 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
828            ),
829        }
830    }
831}
832
833/// Per-agent voice I/O configuration (D1).
834/// Default = disabled so existing profiles continue to load unchanged.
835#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
836pub struct VoiceConfig {
837    /// Whether TTS (Kokoro) + STT (whisper.cpp) are enabled.
838    #[serde(default)]
839    pub enabled: bool,
840    /// Kokoro voice identity for TTS output. Default: af_heart.
841    #[serde(default)]
842    pub voice_id: VoiceId,
843    /// Optional cpal input device name for mic capture.
844    /// None means the OS default input device.
845    #[serde(default, skip_serializing_if = "Option::is_none")]
846    pub input_device: Option<String>,
847}
848
849// ──────────────────────────────────────────────────────────────────────────
850// Companion subsystem (Phase 1.1+) — see
851// docs/superpowers/specs/2026-04-29-mur-companion-phase-1-1-design.md §3.1
852// ──────────────────────────────────────────────────────────────────────────
853
854#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
855pub struct CompanionConfig {
856    #[serde(default)]
857    pub enabled: bool,
858    #[serde(default = "default_locale")]
859    pub locale: String,
860    #[serde(default)]
861    pub relationship: Relationship,
862    #[serde(default)]
863    pub voice_overrides: VoiceOverrides,
864    #[serde(default)]
865    pub onboarding: OnboardingState,
866    #[serde(default)]
867    pub rhythm: RhythmConfig,
868    #[serde(default)]
869    pub proactive: ProactiveConfig,
870}
871
872/// Resolve a default BCP-47 locale from the `LANG` environment variable
873/// (e.g. `zh_TW.UTF-8` → `zh-TW`). Falls back to `en-US`.
874pub fn default_locale() -> String {
875    std::env::var("LANG")
876        .ok()
877        .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
878        .unwrap_or_else(|| "en-US".into())
879}
880
881#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
882pub struct VoiceOverrides {
883    #[serde(default, skip_serializing_if = "Option::is_none")]
884    pub name_for_user: Option<String>,
885    #[serde(default, skip_serializing_if = "Option::is_none")]
886    pub formality: Option<Formality>,
887    #[serde(default, skip_serializing_if = "Option::is_none")]
888    pub extra_instructions: Option<String>,
889}
890
891#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
892pub struct FirstMemory {
893    pub text: String,
894    pub established_at: chrono::DateTime<chrono::Utc>,
895}
896
897#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
898pub struct OnboardingState {
899    #[serde(default, skip_serializing_if = "Option::is_none")]
900    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
901    #[serde(default)]
902    pub version: u32,
903    #[serde(default, skip_serializing_if = "Option::is_none")]
904    pub agent_display_name: Option<String>,
905    #[serde(default, skip_serializing_if = "Option::is_none")]
906    pub first_memory: Option<FirstMemory>,
907}
908
909/// Phase 1.2 reservation. 1.1 keeps `enabled = false` (rhythm collection is
910/// out of 1.1 scope).
911#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
912pub struct RhythmConfig {
913    #[serde(default)]
914    pub enabled: bool,
915}
916
917#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
918pub struct ProactiveConfig {
919    #[serde(default)]
920    pub enabled: bool,
921    /// 1.1 reserves the field; 1.2 will write `now + 7d` at rhythm-enable.
922    #[serde(default, skip_serializing_if = "Option::is_none")]
923    pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
924    #[serde(default, skip_serializing_if = "Option::is_none")]
925    pub quiet_hours: Option<QuietHours>,
926    #[serde(default, skip_serializing_if = "Option::is_none")]
927    pub active_hours: Option<ActiveHours>,
928    #[serde(default = "default_daily_cap")]
929    pub daily_cap: u8,
930    #[serde(default = "default_channels")]
931    pub channels: Vec<String>,
932    #[serde(default, skip_serializing_if = "Option::is_none")]
933    pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
934}
935
936impl Default for ProactiveConfig {
937    fn default() -> Self {
938        Self {
939            enabled: false,
940            learning_until: None,
941            quiet_hours: None,
942            active_hours: None,
943            daily_cap: default_daily_cap(),
944            channels: default_channels(),
945            paused_until: None,
946        }
947    }
948}
949
950fn default_daily_cap() -> u8 {
951    3
952}
953fn default_channels() -> Vec<String> {
954    vec!["stdout".into()]
955}
956
957#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
958pub struct QuietHours {
959    pub start: String,
960    pub end: String,
961}
962
963#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
964pub struct ActiveHours {
965    pub start: String,
966    pub end: String,
967}
968
969// ──────────────────────────────────────────────────────────────────────────
970// Hub companion appearance (M-h3)
971// ──────────────────────────────────────────────────────────────────────────
972
973#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
974pub struct AgentAppearance {
975    /// ID of the active style preset (e.g. "chiikawa", "default-blob").
976    #[serde(default = "default_style_preset")]
977    pub style_preset: String,
978    #[serde(default)]
979    pub behavior_preset: BehaviorPreset,
980    /// Required for the polaroid family; none for all others.
981    #[serde(default, skip_serializing_if = "Option::is_none")]
982    pub source_image_path: Option<std::path::PathBuf>,
983    /// Local dir where rendered .webp expression frames are stored.
984    #[serde(default = "default_expressions_dir")]
985    pub expressions_dir: std::path::PathBuf,
986    #[serde(default, skip_serializing_if = "Option::is_none")]
987    pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
988    #[serde(default)]
989    pub render_status: RenderStatus,
990}
991
992fn default_style_preset() -> String {
993    "default-blob".into()
994}
995
996fn default_expressions_dir() -> std::path::PathBuf {
997    std::path::PathBuf::from("expressions")
998}
999
1000impl Default for AgentAppearance {
1001    fn default() -> Self {
1002        Self {
1003            style_preset: default_style_preset(),
1004            behavior_preset: BehaviorPreset::Normal,
1005            source_image_path: None,
1006            expressions_dir: default_expressions_dir(),
1007            last_rendered_at: None,
1008            render_status: RenderStatus::Pending,
1009        }
1010    }
1011}
1012
1013#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1014#[serde(rename_all = "snake_case")]
1015pub enum BehaviorPreset {
1016    Quiet,
1017    #[default]
1018    Normal,
1019    Lively,
1020}
1021
1022#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1023#[serde(tag = "status", rename_all = "snake_case")]
1024pub enum RenderStatus {
1025    #[default]
1026    Pending,
1027    Rendering {
1028        done: u8,
1029        total: u8,
1030    },
1031    Ready,
1032    Failed {
1033        reason: String,
1034    },
1035}
1036
1037// ──────────────────────────────────────────────────────────────────────────
1038// E6 — Agent Pattern Federation types
1039// ──────────────────────────────────────────────────────────────────────────
1040
1041/// When the agent pulls an updated pattern snapshot from the daemon.
1042#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1043#[serde(rename_all = "kebab-case")]
1044pub enum SnapshotPolicy {
1045    #[default]
1046    PullOnStart,
1047    PullPeriodic,
1048    Manual,
1049}
1050
1051/// Filter criteria for the pattern snapshot written to the agent's patterns_cache.
1052#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1053pub struct PatternFilter {
1054    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1055    pub applies_in: Vec<String>,
1056    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1057    pub tier: Vec<String>,
1058    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1059    pub maturity: Vec<String>,
1060    #[serde(default)]
1061    pub importance_min: f64,
1062    #[serde(default = "default_max_snapshot_count")]
1063    pub max_count: usize,
1064    #[serde(default)]
1065    pub snapshot_policy: SnapshotPolicy,
1066}
1067
1068fn default_max_snapshot_count() -> usize {
1069    200
1070}
1071
1072impl Default for PatternFilter {
1073    fn default() -> Self {
1074        Self {
1075            applies_in: vec![],
1076            tier: vec![],
1077            maturity: vec![],
1078            importance_min: 0.0,
1079            max_count: 200,
1080            snapshot_policy: SnapshotPolicy::default(),
1081        }
1082    }
1083}
1084
1085/// Points to the knowledge-layer commit this agent's patterns_cache was built from.
1086#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1087pub struct SnapshotRef {
1088    pub knowledge_commit: String,
1089    pub taken_at: String,
1090    pub filter: PatternFilter,
1091}
1092
1093/// Federation configuration embedded in AgentProfile (E6).
1094#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1095pub struct FederationConfig {
1096    #[serde(default)]
1097    pub filter: PatternFilter,
1098    #[serde(default, skip_serializing_if = "Option::is_none")]
1099    pub snapshot_ref: Option<SnapshotRef>,
1100    #[serde(default)]
1101    pub evidence_flush_interval_minutes: u32,
1102}
1103
1104impl AgentProfile {
1105    /// Minimal valid profile for tests — no voice, no MCP, no skills.
1106    ///
1107    /// Available in all compilation modes so integration tests in
1108    /// dependent crates can call it (unlike `#[cfg(test)]` items which
1109    /// are invisible to downstream test binaries).
1110    #[doc(hidden)]
1111    pub fn default_for_tests() -> Self {
1112        serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1113            .expect("minimal profile fixture")
1114    }
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119    use super::*;
1120
1121    #[test]
1122    fn profile_round_trip_yaml() {
1123        let yaml = r#"
1124schema: 1
1125id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1126name: agent_a
1127display_name: "Price Hunter"
1128version: "0.1.0"
1129persona:
1130  category: research
1131  description: "Finds prices"
1132  traits: { tone: concise, risk: cautious, verbosity: low }
1133sys_prompt_file: "sys_prompt.md"
1134model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1135mcp_servers: []
1136skills: []
1137transport:
1138  stdio: true
1139  socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1140communication: { accepts_from: ["*"], sends_to: [] }
1141capabilities: ["a2a.message.send", "a2a.tasks"]
1142entitlements:
1143  network:
1144    inbound: { ports: [] }
1145    outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1146  filesystem: { read: [], write: [], deny: [] }
1147  processes: { spawn: { mode: allowlist, allowed: [] } }
1148  syscalls: { mode: default }
1149  limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1150notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1151retry:
1152  llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1153  tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1154lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1155created_at: "2026-04-22T10:00:00+08:00"
1156updated_at: "2026-04-22T10:00:00+08:00"
1157"#;
1158        let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1159        assert_eq!(profile.name, "agent_a");
1160        assert_eq!(profile.persona.category, PersonaCategory::Research);
1161        assert_eq!(
1162            profile.entitlements.network.outbound.mode,
1163            NetworkOutboundMode::Restricted
1164        );
1165        let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1166        let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1167        assert_eq!(profile.id, round_tripped.id);
1168    }
1169}
1170
1171#[cfg(test)]
1172mod model_ref_tests {
1173    use super::*;
1174
1175    #[test]
1176    fn legacy_profile_without_model_ref_still_parses() {
1177        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1178        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1179        assert!(
1180            p.model_ref.is_none(),
1181            "legacy profile must not have model_ref"
1182        );
1183    }
1184
1185    #[test]
1186    fn round_trip_with_model_ref_preserves_field() {
1187        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1188        let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1189        p.model_ref = Some("anthropic_opus_4_7".into());
1190        let s = serde_yaml_ng::to_string(&p).unwrap();
1191        assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1192        let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1193        assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1194    }
1195}
1196
1197/// GUI-facing reification of the companion's three-layer permission toggle.
1198///
1199/// On-disk schema doesn't change — this helper just maps between the
1200/// three independent booleans (`enabled`, `rhythm.enabled`,
1201/// `proactive.enabled`) and a single ordered tier. Use
1202/// [`ProactiveTier::from_config`] to read and [`ProactiveTier::apply`]
1203/// to write.
1204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1205#[serde(rename_all = "snake_case")]
1206pub enum ProactiveTier {
1207    Off,
1208    WarmOnly,
1209    WarmAndBehavior,
1210    All,
1211}
1212
1213impl ProactiveTier {
1214    pub fn from_config(c: &CompanionConfig) -> Self {
1215        match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1216            (false, _, _) => Self::Off,
1217            (true, false, false) => Self::WarmOnly,
1218            (true, true, false) => Self::WarmAndBehavior,
1219            (true, _, true) => Self::All,
1220        }
1221    }
1222
1223    pub fn apply(&self, c: &mut CompanionConfig) {
1224        match self {
1225            Self::Off => {
1226                c.enabled = false;
1227                c.rhythm.enabled = false;
1228                c.proactive.enabled = false;
1229            }
1230            Self::WarmOnly => {
1231                c.enabled = true;
1232                c.rhythm.enabled = false;
1233                c.proactive.enabled = false;
1234            }
1235            Self::WarmAndBehavior => {
1236                c.enabled = true;
1237                c.rhythm.enabled = true;
1238                c.proactive.enabled = false;
1239            }
1240            Self::All => {
1241                c.enabled = true;
1242                c.rhythm.enabled = true;
1243                c.proactive.enabled = true;
1244            }
1245        }
1246    }
1247}
1248
1249#[cfg(test)]
1250mod mcp_pin_tests {
1251    use super::*;
1252
1253    /// Pre-M9 profiles must continue to deserialize with the new
1254    /// optional fields absent. Round-trip: serialize back out and
1255    /// confirm the optional fields don't leak into the YAML.
1256    #[test]
1257    fn pre_m9_entry_roundtrips_without_pin_fields() {
1258        let yaml = r#"
1259name: weather
1260command: /opt/mcp/weather
1261args: ["--port", "0"]
1262"#;
1263        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1264        assert_eq!(entry.name, "weather");
1265        assert_eq!(entry.binary_sha256, None);
1266        assert_eq!(entry.description_hash, None);
1267        assert_eq!(entry.publisher, None);
1268        assert_eq!(entry.installed_at, None);
1269
1270        // skip_serializing_if = "Option::is_none" must keep the YAML
1271        // free of empty pin fields when the entry is pre-M9.
1272        let out = serde_yaml_ng::to_string(&entry).unwrap();
1273        assert!(!out.contains("binary_sha256"), "got {out}");
1274        assert!(!out.contains("description_hash"), "got {out}");
1275        assert!(!out.contains("publisher"), "got {out}");
1276        assert!(!out.contains("installed_at"), "got {out}");
1277    }
1278
1279    /// Full M9 entry with all fields set round-trips losslessly.
1280    #[test]
1281    fn full_m9_entry_roundtrips_all_fields() {
1282        let yaml = r#"
1283name: weather
1284command: /opt/mcp/weather
1285args: []
1286binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1287description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1288publisher:
1289  name: "@anthropic-mcp/weather"
1290  homepage: "https://github.com/anthropic-mcp/weather"
1291  registry_id: "@anthropic-mcp/weather@1.2.3"
1292installed_at: "2026-05-06T08:00:00Z"
1293"#;
1294        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1295        assert!(
1296            entry
1297                .binary_sha256
1298                .as_deref()
1299                .unwrap()
1300                .starts_with("3f4abca8")
1301        );
1302        assert!(
1303            entry
1304                .description_hash
1305                .as_deref()
1306                .unwrap()
1307                .starts_with("9a01b2c3")
1308        );
1309        let pub_info = entry.publisher.clone().unwrap();
1310        assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1311        assert_eq!(
1312            pub_info.homepage.as_deref(),
1313            Some("https://github.com/anthropic-mcp/weather"),
1314        );
1315        assert_eq!(
1316            pub_info.registry_id.as_deref(),
1317            Some("@anthropic-mcp/weather@1.2.3"),
1318        );
1319        let installed = entry.installed_at.unwrap();
1320        assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1321    }
1322
1323    /// Partial — only the binary hash is set (e.g. probe failed but
1324    /// install proceeded). The supervisor still needs to be able to
1325    /// deserialize this without panicking.
1326    #[test]
1327    fn partial_pin_only_binary_sha_roundtrips() {
1328        let yaml = r#"
1329name: weather
1330command: /opt/mcp/weather
1331args: []
1332binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
1333"#;
1334        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1335        assert_eq!(
1336            entry.binary_sha256.as_deref(),
1337            Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
1338        );
1339        assert_eq!(entry.description_hash, None);
1340        assert_eq!(entry.publisher, None);
1341    }
1342
1343    /// Publisher with only the required `name` field — homepage and
1344    /// registry_id are optional.
1345    #[test]
1346    fn publisher_minimal_just_name() {
1347        let yaml = r#"
1348name: weather
1349command: /opt/mcp/weather
1350args: []
1351publisher:
1352  name: "alice"
1353"#;
1354        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1355        let p = entry.publisher.as_ref().unwrap();
1356        assert_eq!(p.name, "alice");
1357        assert_eq!(p.homepage, None);
1358        assert_eq!(p.registry_id, None);
1359
1360        // skip_serializing_if must omit the optional sub-fields too.
1361        let out = serde_yaml_ng::to_string(&entry).unwrap();
1362        assert!(!out.contains("homepage:"), "got {out}");
1363        assert!(!out.contains("registry_id:"), "got {out}");
1364    }
1365}
1366
1367#[cfg(test)]
1368mod voice_tests {
1369    use super::*;
1370    use std::str::FromStr;
1371
1372    #[test]
1373    fn voice_config_round_trips() {
1374        // Base: use the canonical minimal fixture and append a voice: block.
1375        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1376        let yaml = format!("{base}voice:\n  enabled: true\n  voice_id: af_bella\n");
1377
1378        let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
1379        assert!(profile.voice.enabled);
1380        assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
1381
1382        // Legacy profiles (no voice: block) must still load.
1383        let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
1384        assert!(!legacy.voice.enabled);
1385        assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
1386    }
1387
1388    #[test]
1389    fn voice_id_from_str_roundtrips() {
1390        let cases = [
1391            ("af_heart", VoiceId::AfHeart),
1392            ("af_bella", VoiceId::AfBella),
1393            ("af_nicole", VoiceId::AfNicole),
1394            ("am_adam", VoiceId::AmAdam),
1395            ("am_michael", VoiceId::AmMichael),
1396        ];
1397        for (s, expected) in cases {
1398            assert_eq!(VoiceId::from_str(s).unwrap(), expected);
1399            assert_eq!(expected.as_str(), s);
1400        }
1401    }
1402
1403    #[test]
1404    fn voice_id_from_str_rejects_unknown() {
1405        assert!(VoiceId::from_str("bogus").is_err());
1406    }
1407}
1408
1409#[cfg(test)]
1410mod idle_trigger_tests {
1411    use super::*;
1412
1413    #[test]
1414    fn idle_trigger_yaml_round_trip() {
1415        let yaml = r#"
1416restart: on_failure
1417idle_triggers:
1418  - after_secs: 3600
1419    message: "still there?"
1420    sends_to: other_agent
1421    cooldown_secs: 1800
1422    respect_quiet_hours: true
1423"#;
1424        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1425        assert_eq!(cfg.idle_triggers.len(), 1);
1426        assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
1427        assert_eq!(cfg.idle_triggers[0].message, "still there?");
1428        assert_eq!(
1429            cfg.idle_triggers[0].sends_to.as_deref(),
1430            Some("other_agent")
1431        );
1432        assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
1433        assert!(cfg.idle_triggers[0].respect_quiet_hours);
1434    }
1435
1436    #[test]
1437    fn idle_trigger_defaults_when_omitted() {
1438        let yaml = "restart: on_failure\n";
1439        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1440        assert!(cfg.idle_triggers.is_empty());
1441    }
1442}
1443
1444#[cfg(test)]
1445mod appearance_tests {
1446    use super::*;
1447
1448    #[test]
1449    fn appearance_default_style_preset_is_default_blob() {
1450        assert_eq!(AgentAppearance::default().style_preset, "default-blob");
1451    }
1452
1453    #[test]
1454    fn appearance_default_behavior_is_normal() {
1455        assert_eq!(
1456            AgentAppearance::default().behavior_preset,
1457            BehaviorPreset::Normal
1458        );
1459    }
1460
1461    #[test]
1462    fn appearance_default_render_status_is_pending() {
1463        assert_eq!(
1464            AgentAppearance::default().render_status,
1465            RenderStatus::Pending
1466        );
1467    }
1468
1469    #[test]
1470    fn render_status_serde_round_trip() {
1471        let cases = [
1472            RenderStatus::Pending,
1473            RenderStatus::Rendering { done: 3, total: 12 },
1474            RenderStatus::Ready,
1475            RenderStatus::Failed {
1476                reason: "out of quota".into(),
1477            },
1478        ];
1479        for status in cases {
1480            let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
1481            let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
1482            assert_eq!(status, back);
1483        }
1484    }
1485
1486    #[test]
1487    fn agent_profile_with_appearance_round_trips() {
1488        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1489        let yaml = format!(
1490            "{base}appearance:\n  style_preset: chiikawa\n  render_status:\n    status: ready\n"
1491        );
1492        let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
1493        assert_eq!(profile.appearance.style_preset, "chiikawa");
1494        assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
1495
1496        let out = serde_yaml_ng::to_string(&profile).expect("serialize");
1497        let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
1498        assert_eq!(profile.appearance, back.appearance);
1499    }
1500
1501    #[test]
1502    fn legacy_profile_without_appearance_uses_default() {
1503        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1504        let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
1505        assert_eq!(profile.appearance.style_preset, "default-blob");
1506        assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
1507        assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
1508    }
1509
1510    #[test]
1511    fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
1512        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1513        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1514        assert!(p.file_actions.is_empty());
1515        assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
1516        assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
1517    }
1518}
1519
1520#[cfg(test)]
1521mod federation_tests {
1522    use super::*;
1523
1524    #[test]
1525    fn test_pattern_filter_default() {
1526        let f = PatternFilter::default();
1527        assert_eq!(f.max_count, 200);
1528        assert_eq!(f.importance_min, 0.0);
1529        assert!(f.tier.is_empty());
1530    }
1531
1532    #[test]
1533    fn test_federation_config_roundtrip() {
1534        let cfg = FederationConfig {
1535            filter: PatternFilter {
1536                tier: vec!["core".into()],
1537                max_count: 50,
1538                ..Default::default()
1539            },
1540            snapshot_ref: Some(SnapshotRef {
1541                knowledge_commit: "abc123def456".into(),
1542                taken_at: "2026-05-19T00:00:00Z".into(),
1543                filter: PatternFilter::default(),
1544            }),
1545            evidence_flush_interval_minutes: 15,
1546        };
1547        let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
1548        let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1549        assert_eq!(cfg, back);
1550    }
1551
1552    #[test]
1553    fn test_agent_profile_federation_defaults() {
1554        // AgentProfile without a federation block deserializes with FederationConfig::default().
1555        // Use the minimal YAML that passes validation — just the required fields.
1556        // (We check only that the field has its zero value, not full profile parse.)
1557        let cfg = FederationConfig::default();
1558        assert_eq!(cfg.evidence_flush_interval_minutes, 0);
1559        assert!(cfg.snapshot_ref.is_none());
1560    }
1561}
1562
1563#[cfg(test)]
1564mod skill_card_tests {
1565    use super::*;
1566
1567    #[test]
1568    fn installed_skills_default_to_empty_when_absent() {
1569        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1570        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1571        assert!(p.installed_skills.is_empty());
1572    }
1573
1574    #[test]
1575    fn installed_skills_roundtrip_preserves_entries() {
1576        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1577        let yaml = format!(
1578            "{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"
1579        );
1580        let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
1581        assert_eq!(p.installed_skills.len(), 1);
1582        assert_eq!(p.installed_skills[0].name, "s1");
1583        assert_eq!(p.installed_skills[0].abstract_text, "does things");
1584        assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
1585
1586        let out = serde_yaml_ng::to_string(&p).unwrap();
1587        assert!(out.contains("abstract: does things"));
1588        assert!(out.contains("pattern: /find"));
1589
1590        let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
1591        assert_eq!(p.installed_skills, back.installed_skills);
1592    }
1593
1594    #[test]
1595    fn installed_skills_minimal_entry_serializes_compactly() {
1596        // A name-only entry must NOT emit empty string fields.
1597        let entry = SkillCardEntry {
1598            name: "minimal".into(),
1599            ..Default::default()
1600        };
1601        let yaml = serde_yaml_ng::to_string(&entry).unwrap();
1602        assert!(yaml.contains("name: minimal"));
1603        assert!(
1604            !yaml.contains("version:"),
1605            "empty version must be skipped: {yaml}"
1606        );
1607        assert!(
1608            !yaml.contains("publisher:"),
1609            "empty publisher must be skipped: {yaml}"
1610        );
1611        assert!(
1612            !yaml.contains("abstract:"),
1613            "empty abstract must be skipped: {yaml}"
1614        );
1615    }
1616}