Skip to main content

spn_client/
protocol.rs

1//! Protocol types for daemon communication.
2//!
3//! The protocol uses length-prefixed JSON over Unix sockets.
4//!
5//! ## Wire Format
6//!
7//! ```text
8//! [4 bytes: message length (big-endian u32)][JSON payload]
9//! ```
10//!
11//! ## Protocol Versioning
12//!
13//! The protocol version is exchanged during the initial PING/PONG handshake.
14//! This allows clients and daemons to detect incompatible versions early.
15//!
16//! - `protocol_version`: Integer version for wire protocol changes
17//! - `version`: CLI version string for display purposes
18//!
19//! When the protocol version doesn't match, clients should warn and may
20//! fall back to environment variables.
21//!
22//! ## Example
23//!
24//! Request:
25//! ```json
26//! { "cmd": "GET_SECRET", "provider": "anthropic" }
27//! ```
28//!
29//! Response:
30//! ```json
31//! { "ok": true, "secret": "sk-ant-..." }
32//! ```
33
34use serde::{Deserialize, Serialize};
35use spn_core::{LoadConfig, ModelInfo, PullProgress, RunningModel};
36
37// ============================================================================
38// JOB TYPES (IPC-friendly versions)
39// ============================================================================
40
41/// Job state in the scheduler (IPC version).
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "lowercase")]
44pub enum IpcJobState {
45    Pending,
46    Running,
47    Completed,
48    Failed,
49    Cancelled,
50}
51
52impl std::fmt::Display for IpcJobState {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            IpcJobState::Pending => write!(f, "pending"),
56            IpcJobState::Running => write!(f, "running"),
57            IpcJobState::Completed => write!(f, "completed"),
58            IpcJobState::Failed => write!(f, "failed"),
59            IpcJobState::Cancelled => write!(f, "cancelled"),
60        }
61    }
62}
63
64/// Job status for IPC responses.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct IpcJobStatus {
67    /// Job ID (8-char UUID prefix)
68    pub id: String,
69    /// Workflow path
70    pub workflow: String,
71    /// Current state
72    pub state: IpcJobState,
73    /// Optional job name
74    pub name: Option<String>,
75    /// Progress percentage (0-100)
76    pub progress: u8,
77    /// Error message (if failed)
78    pub error: Option<String>,
79    /// Output from the workflow (if completed)
80    pub output: Option<String>,
81    /// Creation timestamp (Unix epoch millis)
82    pub created_at: u64,
83    /// Start timestamp (Unix epoch millis, if started)
84    pub started_at: Option<u64>,
85    /// End timestamp (Unix epoch millis, if finished)
86    pub ended_at: Option<u64>,
87}
88
89/// Scheduler statistics for IPC responses.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct IpcSchedulerStats {
92    /// Total jobs (all states)
93    pub total: usize,
94    /// Pending jobs
95    pub pending: usize,
96    /// Currently running jobs
97    pub running: usize,
98    /// Completed jobs
99    pub completed: usize,
100    /// Failed jobs
101    pub failed: usize,
102    /// Cancelled jobs
103    pub cancelled: usize,
104    /// Whether nika binary is available
105    pub has_nika: bool,
106}
107
108// ============================================================================
109// WATCHER TYPES (IPC-friendly versions)
110// ============================================================================
111
112/// Recent project info for watcher status.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct RecentProjectInfo {
115    /// Absolute path to project root
116    pub path: String,
117    /// Last used timestamp (ISO 8601)
118    pub last_used: String,
119}
120
121/// Foreign MCP info for watcher status.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ForeignMcpInfo {
124    /// Server name (e.g., "github-copilot")
125    pub name: String,
126    /// Source editor: "claude_code", "cursor", "windsurf"
127    pub source: String,
128    /// Scope: "global" or "project:/path/to/project"
129    pub scope: String,
130    /// Detection timestamp (ISO 8601)
131    pub detected: String,
132}
133
134/// Watcher status for IPC responses.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct WatcherStatusInfo {
137    /// Whether the watcher is running
138    pub is_running: bool,
139    /// Number of paths being watched
140    pub watched_count: usize,
141    /// Paths currently being watched
142    pub watched_paths: Vec<String>,
143    /// Debounce duration in milliseconds
144    pub debounce_ms: u64,
145    /// Recently used projects
146    pub recent_projects: Vec<RecentProjectInfo>,
147    /// Foreign MCPs pending adoption
148    pub foreign_pending: Vec<ForeignMcpInfo>,
149    /// Foreign MCPs explicitly ignored
150    pub foreign_ignored: Vec<String>,
151}
152
153/// Progress update for model operations (pull, load, delete).
154///
155/// Used for streaming progress from daemon to CLI during long-running operations.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct ModelProgress {
158    /// Current status message (e.g., "downloading", "verifying", "extracting")
159    pub status: String,
160    /// Bytes/units completed (optional for indeterminate operations)
161    pub completed: Option<u64>,
162    /// Total bytes/units (optional for indeterminate operations)
163    pub total: Option<u64>,
164    /// Model digest (for pull operations)
165    pub digest: Option<String>,
166}
167
168impl ModelProgress {
169    /// Calculate completion percentage (0.0 - 100.0).
170    /// Returns None if total is unknown or zero.
171    pub fn percentage(&self) -> Option<f64> {
172        match (self.completed, self.total) {
173            (Some(completed), Some(total)) if total > 0 => {
174                Some((completed as f64 / total as f64) * 100.0)
175            }
176            _ => None,
177        }
178    }
179
180    /// Create a new indeterminate progress (spinner mode).
181    pub fn indeterminate(status: impl Into<String>) -> Self {
182        Self {
183            status: status.into(),
184            completed: None,
185            total: None,
186            digest: None,
187        }
188    }
189
190    /// Create a determinate progress (progress bar mode).
191    pub fn determinate(status: impl Into<String>, completed: u64, total: u64) -> Self {
192        Self {
193            status: status.into(),
194            completed: Some(completed),
195            total: Some(total),
196            digest: None,
197        }
198    }
199
200    /// Create from PullProgress (from spn_core/spn_ollama).
201    pub fn from_pull_progress(p: &PullProgress) -> Self {
202        Self {
203            status: p.status.clone(),
204            completed: Some(p.completed),
205            total: Some(p.total),
206            digest: None, // PullProgress doesn't have digest field
207        }
208    }
209}
210
211/// Current protocol version.
212/// - Adding required fields to requests/responses
213/// - Changing the serialization format
214/// - Removing commands or response variants
215///
216/// Do NOT increment for:
217/// - Adding new optional fields
218/// - Adding new commands (backwards compatible)
219pub const PROTOCOL_VERSION: u32 = 1;
220
221/// Default protocol version for backwards compatibility.
222/// Old daemons that don't send protocol_version are assumed to be v0.
223fn default_protocol_version() -> u32 {
224    0
225}
226
227/// Request sent to the daemon.
228#[derive(Debug, Clone, Serialize, Deserialize)]
229#[serde(tag = "cmd")]
230pub enum Request {
231    /// Ping the daemon to check it's alive.
232    #[serde(rename = "PING")]
233    Ping,
234
235    /// Get a secret for a provider.
236    #[serde(rename = "GET_SECRET")]
237    GetSecret { provider: String },
238
239    /// Check if a secret exists.
240    #[serde(rename = "HAS_SECRET")]
241    HasSecret { provider: String },
242
243    /// List all available providers.
244    #[serde(rename = "LIST_PROVIDERS")]
245    ListProviders,
246
247    /// Refresh/reload a secret from keychain into daemon cache.
248    /// Used after `spn provider set` to invalidate stale cached values.
249    #[serde(rename = "REFRESH_SECRET")]
250    RefreshSecret { provider: String },
251
252    // ==================== Model Commands ====================
253    /// List all installed models.
254    #[serde(rename = "MODEL_LIST")]
255    ModelList,
256
257    /// Pull/download a model.
258    #[serde(rename = "MODEL_PULL")]
259    ModelPull { name: String },
260
261    /// Load a model into memory.
262    #[serde(rename = "MODEL_LOAD")]
263    ModelLoad {
264        name: String,
265        #[serde(default)]
266        config: Option<LoadConfig>,
267    },
268
269    /// Unload a model from memory.
270    #[serde(rename = "MODEL_UNLOAD")]
271    ModelUnload { name: String },
272
273    /// Get status of running models.
274    #[serde(rename = "MODEL_STATUS")]
275    ModelStatus,
276
277    /// Delete a model.
278    #[serde(rename = "MODEL_DELETE")]
279    ModelDelete { name: String },
280
281    /// Run inference on a model.
282    #[serde(rename = "MODEL_RUN")]
283    ModelRun {
284        /// Model name (e.g., llama3.2)
285        model: String,
286        /// User prompt
287        prompt: String,
288        /// System prompt (optional)
289        #[serde(default)]
290        system: Option<String>,
291        /// Temperature (0.0 - 2.0)
292        #[serde(default)]
293        temperature: Option<f32>,
294        /// Enable streaming (not yet supported via IPC)
295        #[serde(default)]
296        stream: bool,
297    },
298
299    // ==================== Job Commands ====================
300    /// Submit a workflow job for background execution.
301    #[serde(rename = "JOB_SUBMIT")]
302    JobSubmit {
303        /// Path to workflow file
304        workflow: String,
305        /// Optional workflow arguments
306        #[serde(default)]
307        args: Vec<String>,
308        /// Optional job name for display
309        #[serde(default)]
310        name: Option<String>,
311        /// Job priority (higher = more urgent)
312        #[serde(default)]
313        priority: i32,
314    },
315
316    /// Get status of a specific job.
317    #[serde(rename = "JOB_STATUS")]
318    JobStatus {
319        /// Job ID (8-character short UUID)
320        job_id: String,
321    },
322
323    /// List all jobs (optionally filtered by state).
324    #[serde(rename = "JOB_LIST")]
325    JobList {
326        /// Filter by state (pending, running, completed, failed, cancelled)
327        #[serde(default)]
328        state: Option<String>,
329    },
330
331    /// Cancel a running or pending job.
332    #[serde(rename = "JOB_CANCEL")]
333    JobCancel {
334        /// Job ID to cancel
335        job_id: String,
336    },
337
338    /// Get scheduler statistics.
339    #[serde(rename = "JOB_STATS")]
340    JobStats,
341
342    // ==================== Watcher Commands ====================
343    /// Get watcher status (watched paths, foreign MCPs, recent projects).
344    #[serde(rename = "WATCHER_STATUS")]
345    WatcherStatus,
346}
347
348/// Response from the daemon.
349#[derive(Clone, Serialize, Deserialize)]
350#[serde(untagged)]
351pub enum Response {
352    /// Successful ping response with version info.
353    Pong {
354        /// Protocol version for compatibility checking.
355        /// Clients should verify this matches PROTOCOL_VERSION.
356        #[serde(default = "default_protocol_version")]
357        protocol_version: u32,
358        /// CLI version string for display.
359        version: String,
360    },
361
362    /// Secret value response.
363    ///
364    /// # Security Note
365    ///
366    /// The secret is transmitted as plain JSON over the Unix socket. This is secure because:
367    /// - Unix socket requires peer credential verification (same UID only)
368    /// - Socket permissions are 0600 (owner-only)
369    /// - Connection is local-only (no network exposure)
370    Secret { value: String },
371
372    /// Secret existence check response.
373    Exists { exists: bool },
374
375    /// Provider list response.
376    Providers { providers: Vec<String> },
377
378    /// Secret refresh response.
379    Refreshed {
380        /// Whether the secret was found and reloaded
381        refreshed: bool,
382        /// The provider that was refreshed
383        provider: String,
384    },
385
386    // ==================== Model Responses ====================
387    /// List of installed models.
388    Models { models: Vec<ModelInfo> },
389
390    /// List of currently running/loaded models.
391    RunningModels { running: Vec<RunningModel> },
392
393    /// Generic success response.
394    Success { success: bool },
395
396    /// Model run result with generated content.
397    ModelRunResult {
398        /// Generated content from the model.
399        content: String,
400        /// Optional stats (tokens_per_second, etc.)
401        #[serde(default)]
402        stats: Option<serde_json::Value>,
403    },
404
405    /// Error response.
406    Error { message: String },
407
408    // ==================== Streaming Responses ====================
409    /// Progress update for model operations (streaming).
410    Progress {
411        /// Progress details
412        progress: ModelProgress,
413    },
414
415    /// End of stream marker.
416    StreamEnd {
417        /// Whether the operation succeeded
418        success: bool,
419        /// Error message if failed
420        #[serde(default)]
421        error: Option<String>,
422    },
423
424    // ==================== Watcher Responses ====================
425    // NOTE: WatcherStatusResult MUST come before JobStatusResult because
426    // JobStatusResult has Option<...> which matches any JSON with missing fields.
427    // With untagged enums, serde tries variants in order.
428    /// Watcher status response.
429    WatcherStatusResult {
430        /// Watcher status info
431        status: WatcherStatusInfo,
432    },
433
434    // ==================== Job Responses ====================
435    /// Job submitted response with initial status.
436    JobSubmitted {
437        /// The job status
438        job: IpcJobStatus,
439    },
440
441    /// Single job status response.
442    JobStatusResult {
443        /// The job status (None if job not found)
444        job: Option<IpcJobStatus>,
445    },
446
447    /// Job list response.
448    JobListResult {
449        /// List of jobs
450        jobs: Vec<IpcJobStatus>,
451    },
452
453    /// Job cancelled response.
454    JobCancelled {
455        /// Whether cancellation succeeded
456        cancelled: bool,
457        /// Job ID that was cancelled
458        job_id: String,
459    },
460
461    /// Scheduler statistics response.
462    JobStatsResult {
463        /// Scheduler stats
464        stats: IpcSchedulerStats,
465    },
466}
467
468impl std::fmt::Debug for Response {
469    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
470        match self {
471            // SECURITY: Redact secret value to prevent exposure in logs
472            Response::Secret { .. } => write!(f, "Response::Secret {{ value: [REDACTED] }}"),
473            // All other variants use standard debug output
474            Response::Pong {
475                protocol_version,
476                version,
477            } => f
478                .debug_struct("Pong")
479                .field("protocol_version", protocol_version)
480                .field("version", version)
481                .finish(),
482            Response::Exists { exists } => {
483                f.debug_struct("Exists").field("exists", exists).finish()
484            }
485            Response::Providers { providers } => f
486                .debug_struct("Providers")
487                .field("providers", providers)
488                .finish(),
489            Response::Refreshed { refreshed, provider } => f
490                .debug_struct("Refreshed")
491                .field("refreshed", refreshed)
492                .field("provider", provider)
493                .finish(),
494            Response::Models { models } => {
495                f.debug_struct("Models").field("models", models).finish()
496            }
497            Response::RunningModels { running } => f
498                .debug_struct("RunningModels")
499                .field("running", running)
500                .finish(),
501            Response::Success { success } => {
502                f.debug_struct("Success").field("success", success).finish()
503            }
504            Response::ModelRunResult { content, stats } => f
505                .debug_struct("ModelRunResult")
506                .field("content", content)
507                .field("stats", stats)
508                .finish(),
509            Response::Error { message } => {
510                f.debug_struct("Error").field("message", message).finish()
511            }
512            Response::Progress { progress } => f
513                .debug_struct("Progress")
514                .field("progress", progress)
515                .finish(),
516            Response::StreamEnd { success, error } => f
517                .debug_struct("StreamEnd")
518                .field("success", success)
519                .field("error", error)
520                .finish(),
521            Response::WatcherStatusResult { status } => f
522                .debug_struct("WatcherStatusResult")
523                .field("status", status)
524                .finish(),
525            Response::JobSubmitted { job } => {
526                f.debug_struct("JobSubmitted").field("job", job).finish()
527            }
528            Response::JobStatusResult { job } => {
529                f.debug_struct("JobStatusResult").field("job", job).finish()
530            }
531            Response::JobListResult { jobs } => {
532                f.debug_struct("JobListResult").field("jobs", jobs).finish()
533            }
534            Response::JobCancelled { cancelled, job_id } => f
535                .debug_struct("JobCancelled")
536                .field("cancelled", cancelled)
537                .field("job_id", job_id)
538                .finish(),
539            Response::JobStatsResult { stats } => f
540                .debug_struct("JobStatsResult")
541                .field("stats", stats)
542                .finish(),
543        }
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    #[test]
552    fn test_request_serialization() {
553        let ping = Request::Ping;
554        let json = serde_json::to_string(&ping).unwrap();
555        assert_eq!(json, r#"{"cmd":"PING"}"#);
556
557        let get_secret = Request::GetSecret {
558            provider: "anthropic".to_string(),
559        };
560        let json = serde_json::to_string(&get_secret).unwrap();
561        assert_eq!(json, r#"{"cmd":"GET_SECRET","provider":"anthropic"}"#);
562
563        let has_secret = Request::HasSecret {
564            provider: "openai".to_string(),
565        };
566        let json = serde_json::to_string(&has_secret).unwrap();
567        assert_eq!(json, r#"{"cmd":"HAS_SECRET","provider":"openai"}"#);
568
569        let list = Request::ListProviders;
570        let json = serde_json::to_string(&list).unwrap();
571        assert_eq!(json, r#"{"cmd":"LIST_PROVIDERS"}"#);
572    }
573
574    #[test]
575    fn test_response_deserialization() {
576        // Pong with protocol version
577        let json = r#"{"protocol_version":1,"version":"0.14.2"}"#;
578        let response: Response = serde_json::from_str(json).unwrap();
579        assert!(
580            matches!(response, Response::Pong { protocol_version, version }
581                if protocol_version == 1 && version == "0.14.2")
582        );
583
584        // Pong without protocol version (backwards compatibility)
585        let json = r#"{"version":"0.9.0"}"#;
586        let response: Response = serde_json::from_str(json).unwrap();
587        assert!(
588            matches!(response, Response::Pong { protocol_version, version }
589                if protocol_version == 0 && version == "0.9.0")
590        );
591
592        // Secret
593        let json = r#"{"value":"sk-test-123"}"#;
594        let response: Response = serde_json::from_str(json).unwrap();
595        assert!(matches!(response, Response::Secret { value } if value == "sk-test-123"));
596
597        // Exists
598        let json = r#"{"exists":true}"#;
599        let response: Response = serde_json::from_str(json).unwrap();
600        assert!(matches!(response, Response::Exists { exists } if exists));
601
602        // Providers
603        let json = r#"{"providers":["anthropic","openai"]}"#;
604        let response: Response = serde_json::from_str(json).unwrap();
605        assert!(
606            matches!(response, Response::Providers { providers } if providers == vec!["anthropic", "openai"])
607        );
608
609        // Error
610        let json = r#"{"message":"Not found"}"#;
611        let response: Response = serde_json::from_str(json).unwrap();
612        assert!(matches!(response, Response::Error { message } if message == "Not found"));
613    }
614
615    #[test]
616    fn test_model_progress_serialization() {
617        let progress = ModelProgress {
618            status: "downloading".into(),
619            completed: Some(50),
620            total: Some(100),
621            digest: Some("sha256:abc123".into()),
622        };
623
624        let json = serde_json::to_string(&progress).unwrap();
625        let parsed: ModelProgress = serde_json::from_str(&json).unwrap();
626
627        assert_eq!(parsed.status, "downloading");
628        assert_eq!(parsed.completed, Some(50));
629        assert_eq!(parsed.total, Some(100));
630    }
631
632    #[test]
633    fn test_model_progress_percentage() {
634        let progress = ModelProgress {
635            status: "downloading".into(),
636            completed: Some(75),
637            total: Some(100),
638            digest: None,
639        };
640
641        assert_eq!(progress.percentage(), Some(75.0));
642
643        let no_total = ModelProgress {
644            status: "starting".into(),
645            completed: None,
646            total: None,
647            digest: None,
648        };
649
650        assert_eq!(no_total.percentage(), None);
651    }
652
653    #[test]
654    fn test_model_progress_constructors() {
655        let indeterminate = ModelProgress::indeterminate("loading");
656        assert_eq!(indeterminate.status, "loading");
657        assert!(indeterminate.percentage().is_none());
658
659        let determinate = ModelProgress::determinate("downloading", 50, 100);
660        assert_eq!(determinate.percentage(), Some(50.0));
661    }
662
663    #[test]
664    fn test_response_progress_variant() {
665        let progress = ModelProgress::determinate("downloading", 50, 100);
666        let response = Response::Progress { progress };
667
668        let json = serde_json::to_string(&response).unwrap();
669        assert!(json.contains("downloading"));
670    }
671
672    #[test]
673    fn test_response_stream_end_variant() {
674        let success_response = Response::StreamEnd {
675            success: true,
676            error: None,
677        };
678        let json = serde_json::to_string(&success_response).unwrap();
679        assert!(json.contains("success"));
680
681        let error_response = Response::StreamEnd {
682            success: false,
683            error: Some("Connection lost".into()),
684        };
685        let json = serde_json::to_string(&error_response).unwrap();
686        assert!(json.contains("Connection lost"));
687    }
688
689    // ==================== Job Protocol Tests ====================
690
691    #[test]
692    fn test_job_request_serialization() {
693        let submit = Request::JobSubmit {
694            workflow: "/path/to/workflow.yaml".into(),
695            args: vec!["--verbose".into()],
696            name: Some("Test Job".into()),
697            priority: 5,
698        };
699        let json = serde_json::to_string(&submit).unwrap();
700        assert!(json.contains("JOB_SUBMIT"));
701        assert!(json.contains("workflow.yaml"));
702
703        let status = Request::JobStatus {
704            job_id: "abc12345".into(),
705        };
706        let json = serde_json::to_string(&status).unwrap();
707        assert!(json.contains("JOB_STATUS"));
708        assert!(json.contains("abc12345"));
709
710        let list = Request::JobList { state: None };
711        let json = serde_json::to_string(&list).unwrap();
712        assert!(json.contains("JOB_LIST"));
713
714        let cancel = Request::JobCancel {
715            job_id: "def67890".into(),
716        };
717        let json = serde_json::to_string(&cancel).unwrap();
718        assert!(json.contains("JOB_CANCEL"));
719
720        let stats = Request::JobStats;
721        let json = serde_json::to_string(&stats).unwrap();
722        assert!(json.contains("JOB_STATS"));
723    }
724
725    #[test]
726    fn test_ipc_job_state_serialization() {
727        assert_eq!(
728            serde_json::to_string(&IpcJobState::Pending).unwrap(),
729            r#""pending""#
730        );
731        assert_eq!(
732            serde_json::to_string(&IpcJobState::Running).unwrap(),
733            r#""running""#
734        );
735        assert_eq!(
736            serde_json::to_string(&IpcJobState::Completed).unwrap(),
737            r#""completed""#
738        );
739        assert_eq!(
740            serde_json::to_string(&IpcJobState::Failed).unwrap(),
741            r#""failed""#
742        );
743        assert_eq!(
744            serde_json::to_string(&IpcJobState::Cancelled).unwrap(),
745            r#""cancelled""#
746        );
747    }
748
749    #[test]
750    fn test_ipc_job_status_serialization() {
751        let status = IpcJobStatus {
752            id: "abc12345".into(),
753            workflow: "/path/to/test.yaml".into(),
754            state: IpcJobState::Running,
755            name: Some("Test Job".into()),
756            progress: 50,
757            error: None,
758            output: None,
759            created_at: 1710000000000,
760            started_at: Some(1710000001000),
761            ended_at: None,
762        };
763
764        let json = serde_json::to_string(&status).unwrap();
765        assert!(json.contains("abc12345"));
766        assert!(json.contains("running"));
767        assert!(json.contains("Test Job"));
768    }
769
770    #[test]
771    fn test_ipc_scheduler_stats_serialization() {
772        let stats = IpcSchedulerStats {
773            total: 10,
774            pending: 2,
775            running: 3,
776            completed: 4,
777            failed: 1,
778            cancelled: 0,
779            has_nika: true,
780        };
781
782        let json = serde_json::to_string(&stats).unwrap();
783        let parsed: IpcSchedulerStats = serde_json::from_str(&json).unwrap();
784
785        assert_eq!(parsed.total, 10);
786        assert_eq!(parsed.running, 3);
787        assert!(parsed.has_nika);
788    }
789
790    #[test]
791    fn test_job_response_variants() {
792        // JobSubmitted
793        let status = IpcJobStatus {
794            id: "abc12345".into(),
795            workflow: "/test.yaml".into(),
796            state: IpcJobState::Pending,
797            name: None,
798            progress: 0,
799            error: None,
800            output: None,
801            created_at: 1710000000000,
802            started_at: None,
803            ended_at: None,
804        };
805        let response = Response::JobSubmitted { job: status };
806        let json = serde_json::to_string(&response).unwrap();
807        assert!(json.contains("abc12345"));
808
809        // JobCancelled
810        let response = Response::JobCancelled {
811            cancelled: true,
812            job_id: "def67890".into(),
813        };
814        let json = serde_json::to_string(&response).unwrap();
815        assert!(json.contains("cancelled"));
816        assert!(json.contains("def67890"));
817    }
818
819    // ==================== Watcher Protocol Tests ====================
820
821    #[test]
822    fn test_watcher_request_serialization() {
823        let request = Request::WatcherStatus;
824        let json = serde_json::to_string(&request).unwrap();
825        assert_eq!(json, r#"{"cmd":"WATCHER_STATUS"}"#);
826    }
827
828    #[test]
829    fn test_watcher_status_info_serialization() {
830        let status = WatcherStatusInfo {
831            is_running: true,
832            watched_count: 8,
833            watched_paths: vec!["~/.spn/mcp.yaml".into(), "~/.claude.json".into()],
834            debounce_ms: 500,
835            recent_projects: vec![RecentProjectInfo {
836                path: "/Users/test/project".into(),
837                last_used: "2026-03-09T12:00:00Z".into(),
838            }],
839            foreign_pending: vec![ForeignMcpInfo {
840                name: "github-copilot".into(),
841                source: "cursor".into(),
842                scope: "global".into(),
843                detected: "2026-03-09T11:00:00Z".into(),
844            }],
845            foreign_ignored: vec!["some-mcp".into()],
846        };
847
848        let json = serde_json::to_string(&status).unwrap();
849        assert!(json.contains("is_running"));
850        assert!(json.contains("watched_count"));
851        assert!(json.contains("github-copilot"));
852
853        // Verify round-trip
854        let parsed: WatcherStatusInfo = serde_json::from_str(&json).unwrap();
855        assert!(parsed.is_running);
856        assert_eq!(parsed.watched_count, 8);
857        assert_eq!(parsed.recent_projects.len(), 1);
858        assert_eq!(parsed.foreign_pending.len(), 1);
859    }
860
861    #[test]
862    fn test_watcher_status_response_variant() {
863        let status = WatcherStatusInfo {
864            is_running: true,
865            watched_count: 5,
866            watched_paths: vec![],
867            debounce_ms: 500,
868            recent_projects: vec![],
869            foreign_pending: vec![],
870            foreign_ignored: vec![],
871        };
872        let response = Response::WatcherStatusResult { status };
873        let json = serde_json::to_string(&response).unwrap();
874        assert!(json.contains("is_running"));
875        assert!(json.contains("watched_count"));
876    }
877
878    // ==================== Security Tests ====================
879
880    #[test]
881    fn test_response_secret_debug_redacted() {
882        let secret = Response::Secret {
883            value: "sk-ant-api03-super-secret-key".to_string(),
884        };
885        let debug_output = format!("{:?}", secret);
886
887        // Secret value must NOT appear in debug output
888        assert!(
889            !debug_output.contains("sk-ant"),
890            "Secret value leaked in Debug output: {debug_output}"
891        );
892        assert!(
893            !debug_output.contains("super-secret"),
894            "Secret value leaked in Debug output: {debug_output}"
895        );
896
897        // Should show redacted placeholder
898        assert!(
899            debug_output.contains("[REDACTED]"),
900            "Debug output should contain [REDACTED]: {debug_output}"
901        );
902    }
903
904    #[test]
905    fn test_response_other_variants_debug_normal() {
906        // Verify other variants still have normal debug output
907        let pong = Response::Pong {
908            protocol_version: 1,
909            version: "0.15.0".to_string(),
910        };
911        let debug_output = format!("{:?}", pong);
912        assert!(debug_output.contains("protocol_version"));
913        assert!(debug_output.contains("0.15.0"));
914
915        let error = Response::Error {
916            message: "test error".to_string(),
917        };
918        let debug_output = format!("{:?}", error);
919        assert!(debug_output.contains("test error"));
920    }
921}