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