Skip to main content

brainwires_network/remote/
protocol.rs

1//! Protocol types for CLI <-> Backend communication
2//!
3//! Defines the message format for the remote control WebSocket connection.
4
5use serde::{Deserialize, Serialize};
6
7// ============================================================================
8// Protocol Version Constants
9// ============================================================================
10
11/// Current protocol version
12pub const PROTOCOL_VERSION: &str = "1.1";
13
14/// Minimum supported protocol version
15pub const MIN_PROTOCOL_VERSION: &str = "1.0";
16
17/// All supported protocol versions (newest first)
18pub const SUPPORTED_VERSIONS: &[&str] = &["1.1", "1.0"];
19
20// ============================================================================
21// Protocol Capabilities
22// ============================================================================
23
24/// Capabilities that can be negotiated between CLI and backend
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ProtocolCapability {
28    /// Real-time streaming of agent output
29    Streaming,
30    /// Tool execution support
31    Tools,
32    /// Presence tracking (who's viewing)
33    Presence,
34    /// Message compression for large payloads
35    Compression,
36    /// File attachment support
37    Attachments,
38    /// Command priority queuing
39    Priority,
40    /// Telemetry and metrics
41    Telemetry,
42    /// Device allowlist / permission relay
43    DeviceAllowlist,
44    /// Remote tool approval prompts
45    PermissionRelay,
46}
47
48impl ProtocolCapability {
49    /// Get all capabilities supported by this CLI version
50    pub fn all_supported() -> Vec<Self> {
51        vec![
52            Self::Streaming,
53            Self::Tools,
54            Self::Attachments,
55            Self::Priority,
56            Self::DeviceAllowlist,
57            Self::PermissionRelay,
58        ]
59    }
60}
61
62// ============================================================================
63// Command Priority (Phase 5)
64// ============================================================================
65
66/// Priority level for commands
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
68#[serde(rename_all = "snake_case")]
69#[derive(Default)]
70pub enum CommandPriority {
71    /// Critical commands (e.g., emergency stop, security)
72    Critical = 0,
73    /// High priority (e.g., user-initiated actions)
74    High = 1,
75    /// Normal priority (default)
76    #[default]
77    Normal = 2,
78    /// Low priority (background tasks)
79    Low = 3,
80}
81
82/// Retry policy for failed commands
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct RetryPolicy {
85    /// Maximum number of retry attempts
86    pub max_attempts: u32,
87    /// Backoff multiplier (e.g., 2.0 for exponential backoff)
88    pub backoff_multiplier: f32,
89    /// Initial delay in milliseconds
90    #[serde(default = "default_initial_delay")]
91    pub initial_delay_ms: u64,
92}
93
94fn default_initial_delay() -> u64 {
95    100
96}
97
98fn default_permission_timeout() -> u32 {
99    60
100}
101
102impl Default for RetryPolicy {
103    fn default() -> Self {
104        Self {
105            max_attempts: 3,
106            backoff_multiplier: 2.0,
107            initial_delay_ms: 100,
108        }
109    }
110}
111
112/// Wrapper for prioritized commands
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct PrioritizedCommand {
115    /// The underlying command
116    pub command: BackendCommand,
117    /// Priority level
118    #[serde(default)]
119    pub priority: CommandPriority,
120    /// Optional deadline in milliseconds from now
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub deadline_ms: Option<u64>,
123    /// Optional retry policy
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub retry_policy: Option<RetryPolicy>,
126}
127
128// ============================================================================
129// Protocol Negotiation Messages
130// ============================================================================
131
132/// Protocol hello message sent by CLI during registration
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ProtocolHello {
135    /// Protocol versions supported by this CLI (newest first)
136    pub supported_versions: Vec<String>,
137    /// Preferred protocol version
138    pub preferred_version: String,
139    /// Capabilities this CLI supports
140    pub capabilities: Vec<ProtocolCapability>,
141}
142
143impl Default for ProtocolHello {
144    fn default() -> Self {
145        Self {
146            supported_versions: SUPPORTED_VERSIONS.iter().map(|s| s.to_string()).collect(),
147            preferred_version: PROTOCOL_VERSION.to_string(),
148            capabilities: ProtocolCapability::all_supported(),
149        }
150    }
151}
152
153/// Protocol accept message sent by backend in response
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct ProtocolAccept {
156    /// Selected protocol version
157    pub selected_version: String,
158    /// Capabilities enabled for this session
159    pub enabled_capabilities: Vec<ProtocolCapability>,
160}
161
162impl Default for ProtocolAccept {
163    fn default() -> Self {
164        Self {
165            selected_version: PROTOCOL_VERSION.to_string(),
166            enabled_capabilities: vec![ProtocolCapability::Streaming, ProtocolCapability::Tools],
167        }
168    }
169}
170
171/// Negotiated protocol state after handshake
172#[derive(Debug, Clone)]
173pub struct NegotiatedProtocol {
174    /// The agreed-upon protocol version
175    pub version: String,
176    /// Capabilities enabled for this session
177    pub capabilities: Vec<ProtocolCapability>,
178}
179
180impl NegotiatedProtocol {
181    /// Check if a capability is enabled
182    pub fn has_capability(&self, cap: ProtocolCapability) -> bool {
183        self.capabilities.contains(&cap)
184    }
185
186    /// Create from protocol accept response
187    pub fn from_accept(accept: ProtocolAccept) -> Self {
188        Self {
189            version: accept.selected_version,
190            capabilities: accept.enabled_capabilities,
191        }
192    }
193}
194
195impl Default for NegotiatedProtocol {
196    fn default() -> Self {
197        Self {
198            version: PROTOCOL_VERSION.to_string(),
199            capabilities: vec![ProtocolCapability::Streaming, ProtocolCapability::Tools],
200        }
201    }
202}
203
204// ============================================================================
205// CLI -> Backend Messages
206// ============================================================================
207
208/// Messages FROM CLI TO Backend
209#[derive(Debug, Clone, Serialize, Deserialize)]
210#[serde(tag = "type", rename_all = "snake_case")]
211pub enum RemoteMessage {
212    /// Initial registration with API key and protocol negotiation
213    Register {
214        /// API key for authentication.
215        api_key: String,
216        /// Client hostname.
217        hostname: String,
218        /// Client operating system.
219        os: String,
220        /// Client version string.
221        version: String,
222        /// Protocol negotiation (optional for backward compatibility)
223        #[serde(skip_serializing_if = "Option::is_none")]
224        protocol: Option<ProtocolHello>,
225        /// Stable device fingerprint — SHA-256(machine_id || hostname || os).
226        /// Used for device allowlist verification by the backend.
227        #[serde(skip_serializing_if = "Option::is_none")]
228        device_fingerprint: Option<String>,
229    },
230
231    /// Regular heartbeat with agent status
232    Heartbeat {
233        /// Session token for authentication.
234        session_token: String,
235        /// List of active agents.
236        agents: Vec<RemoteAgentInfo>,
237        /// Current system load (0.0-1.0).
238        system_load: f32,
239    },
240
241    /// Response to a command
242    CommandResult {
243        /// ID of the command being responded to.
244        command_id: String,
245        /// Whether the command succeeded.
246        success: bool,
247        /// Result data if successful.
248        #[serde(skip_serializing_if = "Option::is_none")]
249        result: Option<serde_json::Value>,
250        /// Error message if failed.
251        #[serde(skip_serializing_if = "Option::is_none")]
252        error: Option<String>,
253    },
254
255    /// Agent event (new agent, agent exit, state change)
256    AgentEvent {
257        /// Type of agent event.
258        event_type: AgentEventType,
259        /// ID of the agent this event relates to.
260        agent_id: String,
261        /// Event-specific data payload.
262        data: serde_json::Value,
263    },
264
265    /// Stream chunk from agent (for real-time viewing)
266    AgentStream {
267        /// ID of the agent producing the stream.
268        agent_id: String,
269        /// Type of stream chunk.
270        chunk_type: StreamChunkType,
271        /// Chunk content text.
272        content: String,
273    },
274
275    /// Pong response to backend ping
276    Pong {
277        /// Timestamp from the original ping.
278        timestamp: i64,
279    },
280
281    // ========================================================================
282    // Permission Relay (Phase 6)
283    // ========================================================================
284    /// Request permission from the remote user for a dangerous tool call
285    PermissionRequest {
286        /// Unique request identifier.
287        request_id: String,
288        /// Agent session ID that wants to execute the tool.
289        agent_id: String,
290        /// Tool name (e.g., "bash", "delete_file", "git_push").
291        tool_name: String,
292        /// Human-readable description of what the tool will do.
293        action: String,
294        /// Detailed context (command, file paths, etc.) as JSON.
295        #[serde(default)]
296        details: serde_json::Value,
297        /// Timeout in seconds (after which the request is auto-denied).
298        #[serde(default = "default_permission_timeout")]
299        timeout_secs: u32,
300    },
301
302    // ========================================================================
303    // Attachment Responses (Phase 3)
304    // ========================================================================
305    /// Acknowledgment that attachment was received
306    AttachmentReceived {
307        /// The attachment ID
308        attachment_id: String,
309        /// Whether the attachment was successfully processed
310        success: bool,
311        /// Path where the file was saved (if successful)
312        #[serde(skip_serializing_if = "Option::is_none")]
313        file_path: Option<String>,
314        /// Error message if failed
315        #[serde(skip_serializing_if = "Option::is_none")]
316        error: Option<String>,
317    },
318}
319
320// ============================================================================
321// Backend -> CLI Messages
322// ============================================================================
323
324/// Messages FROM Backend TO CLI
325#[derive(Debug, Clone, Serialize, Deserialize)]
326#[serde(tag = "type", rename_all = "snake_case")]
327pub enum BackendCommand {
328    /// Authenticated - here's your session token and negotiated protocol
329    Authenticated {
330        /// Session token for subsequent requests.
331        session_token: String,
332        /// Authenticated user ID.
333        user_id: String,
334        /// Interval in seconds between heartbeats.
335        refresh_interval_secs: u32,
336        /// Negotiated protocol (optional for backward compatibility)
337        #[serde(skip_serializing_if = "Option::is_none")]
338        protocol: Option<ProtocolAccept>,
339        /// Device allowlist status returned by backend.
340        #[serde(skip_serializing_if = "Option::is_none")]
341        device_status: Option<DeviceStatus>,
342        /// Organization-level policies (enforced client-side, verified server-side).
343        #[serde(skip_serializing_if = "Option::is_none")]
344        org_policies: Option<OrgPolicies>,
345    },
346
347    /// Send input to an agent
348    SendInput {
349        /// Unique command identifier.
350        command_id: String,
351        /// Target agent ID.
352        agent_id: String,
353        /// Input content to send.
354        content: String,
355    },
356
357    /// Execute slash command on agent
358    SlashCommand {
359        /// Unique command identifier.
360        command_id: String,
361        /// Target agent ID.
362        agent_id: String,
363        /// Slash command name.
364        command: String,
365        /// Command arguments.
366        args: Vec<String>,
367    },
368
369    /// Cancel current operation
370    CancelOperation {
371        /// Unique command identifier.
372        command_id: String,
373        /// Target agent ID.
374        agent_id: String,
375    },
376
377    /// Subscribe to agent stream
378    Subscribe {
379        /// Agent ID to subscribe to.
380        agent_id: String,
381    },
382
383    /// Unsubscribe from agent stream
384    Unsubscribe {
385        /// Agent ID to unsubscribe from.
386        agent_id: String,
387    },
388
389    /// Spawn new agent
390    SpawnAgent {
391        /// Unique command identifier.
392        command_id: String,
393        /// Model to use for the new agent.
394        #[serde(skip_serializing_if = "Option::is_none")]
395        model: Option<String>,
396        /// Working directory for the new agent.
397        #[serde(skip_serializing_if = "Option::is_none")]
398        working_directory: Option<String>,
399    },
400
401    /// Request full sync of all agents
402    RequestSync,
403
404    /// Ping to check connection health
405    Ping {
406        /// Timestamp for round-trip measurement.
407        timestamp: i64,
408    },
409
410    /// Disconnect (server closing)
411    Disconnect {
412        /// Reason for disconnection.
413        reason: String,
414    },
415
416    /// Authentication failed
417    AuthenticationFailed {
418        /// Error message describing the failure.
419        error: String,
420    },
421
422    // ========================================================================
423    // Attachment Commands (Phase 3)
424    // ========================================================================
425    /// Start of an attachment upload
426    AttachmentUpload {
427        /// Unique command identifier.
428        command_id: String,
429        /// Target agent ID.
430        agent_id: String,
431        /// Unique ID for this attachment
432        attachment_id: String,
433        /// Original filename
434        filename: String,
435        /// MIME type (e.g., "text/plain", "image/png")
436        mime_type: String,
437        /// Total size in bytes (uncompressed)
438        size: u64,
439        /// Whether the data is compressed
440        compressed: bool,
441        /// Compression algorithm used (if compressed)
442        #[serde(skip_serializing_if = "Option::is_none")]
443        compression_algorithm: Option<CompressionAlgorithm>,
444        /// Total number of chunks
445        chunks_total: u32,
446    },
447
448    /// A chunk of attachment data
449    AttachmentChunk {
450        /// Attachment ID this chunk belongs to
451        attachment_id: String,
452        /// Chunk index (0-based)
453        chunk_index: u32,
454        /// Base64-encoded data
455        data: String,
456        /// Whether this is the final chunk
457        is_final: bool,
458    },
459
460    // ========================================================================
461    // Permission Relay (Phase 6)
462    // ========================================================================
463    /// Response to a permission request from the remote user
464    PermissionResponse {
465        /// The request_id from the original PermissionRequest.
466        request_id: String,
467        /// Whether the tool execution was approved.
468        approved: bool,
469        /// Remember this decision for the rest of the session.
470        #[serde(default)]
471        remember_for_session: bool,
472        /// If true, always allow this tool (no future prompts).
473        #[serde(default)]
474        always_allow: bool,
475    },
476
477    /// Attachment upload complete - verify checksum
478    AttachmentComplete {
479        /// ID of the completed attachment.
480        attachment_id: String,
481        /// SHA-256 checksum of the complete (uncompressed) file
482        checksum: String,
483    },
484}
485
486// ============================================================================
487// Device Allowlist & Organization Policies
488// ============================================================================
489
490/// Device allowlist status returned by backend during authentication.
491#[derive(Debug, Clone, Serialize, Deserialize)]
492#[serde(rename_all = "snake_case")]
493pub enum DeviceStatus {
494    /// Device is recognized and allowed.
495    Allowed,
496    /// Device is new and pending user approval (approve_new mode).
497    PendingApproval,
498    /// Device is explicitly blocked.
499    Blocked,
500}
501
502/// Organization-level policies that override user settings.
503///
504/// Sent by the backend during authentication. The CLI must enforce these
505/// locally for fast UX, but the server also validates server-side.
506#[derive(Debug, Clone, Default, Serialize, Deserialize)]
507pub struct OrgPolicies {
508    /// Tools that are always blocked by org policy.
509    #[serde(default)]
510    pub blocked_tools: Vec<String>,
511    /// Whether dangerous tool calls must go through permission relay.
512    #[serde(default)]
513    pub permission_relay_required: bool,
514    /// Device allowlist mode enforced by org (overrides user preference).
515    #[serde(skip_serializing_if = "Option::is_none")]
516    pub device_allowlist_mode: Option<String>,
517    /// Whether all commands must be audit-logged.
518    #[serde(default)]
519    pub audit_all_commands: bool,
520}
521
522/// Compression algorithms supported for attachments
523#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
524#[serde(rename_all = "snake_case")]
525pub enum CompressionAlgorithm {
526    /// Zstandard compression (fast, good ratio)
527    Zstd,
528    /// Gzip compression (widely compatible)
529    Gzip,
530}
531
532/// Information about a remote agent
533#[derive(Debug, Clone, Serialize, Deserialize)]
534pub struct RemoteAgentInfo {
535    /// Unique session ID of the agent
536    pub session_id: String,
537    /// AI model being used (e.g., "claude-3-5-sonnet")
538    pub model: String,
539    /// Whether the agent is currently processing
540    pub is_busy: bool,
541    /// Parent agent ID (if this is a child agent)
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub parent_id: Option<String>,
544    /// Working directory of the agent
545    pub working_directory: String,
546    /// Number of messages in conversation
547    pub message_count: usize,
548    /// Unix timestamp of last activity
549    pub last_activity: i64,
550    /// Current status description
551    pub status: String,
552    /// Agent name (if set)
553    #[serde(skip_serializing_if = "Option::is_none")]
554    pub name: Option<String>,
555}
556
557/// Types of agent events
558#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
559#[serde(rename_all = "snake_case")]
560pub enum AgentEventType {
561    /// New agent spawned
562    Spawned,
563    /// Agent exited
564    Exited,
565    /// Agent became busy (processing)
566    Busy,
567    /// Agent became idle
568    Idle,
569    /// Agent state changed
570    StateChanged,
571    /// Agent received viewer connection
572    ViewerConnected,
573    /// Agent lost viewer connection
574    ViewerDisconnected,
575}
576
577/// Types of stream chunks
578#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
579#[serde(rename_all = "snake_case")]
580pub enum StreamChunkType {
581    /// Text content from assistant
582    Text,
583    /// Thinking/reasoning content
584    Thinking,
585    /// Tool call information
586    ToolCall,
587    /// Tool result
588    ToolResult,
589    /// Error message
590    Error,
591    /// System message
592    System,
593    /// Stream completed
594    Complete,
595    /// Initial conversation history (JSON array of messages)
596    History,
597    /// User input (from TUI or other source)
598    UserInput,
599}
600
601// ============================================================================
602// Device Fingerprint
603// ============================================================================
604
605/// Compute a stable device fingerprint: SHA-256(machine_id || hostname || os).
606///
607/// The fingerprint is stable across reboots but unique per machine.
608/// - Linux: reads `/etc/machine-id`
609/// - macOS: reads `IOPlatformUUID` via `ioreg`
610/// - Windows: reads `MachineGuid` from registry via `reg query`
611/// - Fallback: uses hostname + OS (less stable but always available)
612pub fn compute_device_fingerprint() -> String {
613    use sha2::{Digest, Sha256};
614
615    let machine_id = get_machine_id().unwrap_or_default();
616    let hostname = gethostname::gethostname().to_string_lossy().to_string();
617    let os = std::env::consts::OS;
618
619    let mut hasher = Sha256::new();
620    hasher.update(machine_id.as_bytes());
621    hasher.update(hostname.as_bytes());
622    hasher.update(os.as_bytes());
623    hex::encode(hasher.finalize())
624}
625
626/// Read the platform-specific machine identifier.
627fn get_machine_id() -> Option<String> {
628    #[cfg(target_os = "linux")]
629    {
630        std::fs::read_to_string("/etc/machine-id")
631            .ok()
632            .map(|s| s.trim().to_string())
633            .filter(|s| !s.is_empty())
634    }
635    #[cfg(target_os = "macos")]
636    {
637        std::process::Command::new("ioreg")
638            .args(["-rd1", "-c", "IOPlatformExpertDevice"])
639            .output()
640            .ok()
641            .and_then(|output| {
642                let stdout = String::from_utf8_lossy(&output.stdout);
643                for line in stdout.lines() {
644                    if line.contains("IOPlatformUUID") {
645                        return line
646                            .split('=')
647                            .nth(1)
648                            .map(|s| s.trim().trim_matches('"').to_string());
649                    }
650                }
651                None
652            })
653    }
654    #[cfg(target_os = "windows")]
655    {
656        std::process::Command::new("reg")
657            .args([
658                "query",
659                r"HKLM\SOFTWARE\Microsoft\Cryptography",
660                "/v",
661                "MachineGuid",
662            ])
663            .output()
664            .ok()
665            .and_then(|output| {
666                let stdout = String::from_utf8_lossy(&output.stdout);
667                for line in stdout.lines() {
668                    if line.contains("MachineGuid") {
669                        return line.split_whitespace().last().map(|s| s.to_string());
670                    }
671                }
672                None
673            })
674    }
675    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
676    {
677        None
678    }
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    #[test]
686    fn test_remote_message_serialization() {
687        let msg = RemoteMessage::Register {
688            api_key: "bw_prod_test123".to_string(),
689            hostname: "my-laptop".to_string(),
690            os: "linux".to_string(),
691            version: "0.7.0".to_string(),
692            protocol: None,
693            device_fingerprint: None,
694        };
695
696        let json = serde_json::to_string(&msg).unwrap();
697        assert!(json.contains("\"type\":\"register\""));
698        assert!(json.contains("\"api_key\":\"bw_prod_test123\""));
699    }
700
701    #[test]
702    fn test_remote_message_with_protocol() {
703        let msg = RemoteMessage::Register {
704            api_key: "bw_prod_test123".to_string(),
705            hostname: "my-laptop".to_string(),
706            os: "linux".to_string(),
707            version: "0.7.0".to_string(),
708            protocol: Some(ProtocolHello::default()),
709            device_fingerprint: Some("abc123def456".to_string()),
710        };
711
712        let json = serde_json::to_string(&msg).unwrap();
713        assert!(json.contains("\"protocol\""));
714        assert!(json.contains("\"preferred_version\":\"1.1\""));
715        assert!(json.contains("\"streaming\""));
716    }
717
718    #[test]
719    fn test_backend_command_deserialization() {
720        // Test backward compatibility - no protocol field
721        let json = r#"{"type":"authenticated","session_token":"abc123","user_id":"user-456","refresh_interval_secs":30}"#;
722        let cmd: BackendCommand = serde_json::from_str(json).unwrap();
723
724        match cmd {
725            BackendCommand::Authenticated {
726                session_token,
727                user_id,
728                refresh_interval_secs,
729                protocol,
730                device_status,
731                org_policies,
732            } => {
733                assert_eq!(session_token, "abc123");
734                assert_eq!(user_id, "user-456");
735                assert_eq!(refresh_interval_secs, 30);
736                assert!(protocol.is_none());
737                assert!(device_status.is_none());
738                assert!(org_policies.is_none());
739            }
740            _ => panic!("Expected Authenticated command"),
741        }
742    }
743
744    #[test]
745    fn test_backend_command_with_protocol() {
746        let json = r#"{"type":"authenticated","session_token":"abc123","user_id":"user-456","refresh_interval_secs":30,"protocol":{"selected_version":"1.1","enabled_capabilities":["streaming","tools"]}}"#;
747        let cmd: BackendCommand = serde_json::from_str(json).unwrap();
748
749        match cmd {
750            BackendCommand::Authenticated { protocol, .. } => {
751                let proto = protocol.expect("Expected protocol");
752                assert_eq!(proto.selected_version, "1.1");
753                assert!(
754                    proto
755                        .enabled_capabilities
756                        .contains(&ProtocolCapability::Streaming)
757                );
758                assert!(
759                    proto
760                        .enabled_capabilities
761                        .contains(&ProtocolCapability::Tools)
762                );
763            }
764            _ => panic!("Expected Authenticated command"),
765        }
766    }
767
768    #[test]
769    fn test_protocol_capability_serialization() {
770        let cap = ProtocolCapability::Streaming;
771        let json = serde_json::to_string(&cap).unwrap();
772        assert_eq!(json, "\"streaming\"");
773
774        let cap: ProtocolCapability = serde_json::from_str("\"attachments\"").unwrap();
775        assert_eq!(cap, ProtocolCapability::Attachments);
776    }
777
778    #[test]
779    fn test_negotiated_protocol() {
780        let accept = ProtocolAccept {
781            selected_version: "1.1".to_string(),
782            enabled_capabilities: vec![
783                ProtocolCapability::Streaming,
784                ProtocolCapability::Compression,
785            ],
786        };
787
788        let negotiated = NegotiatedProtocol::from_accept(accept);
789        assert!(negotiated.has_capability(ProtocolCapability::Streaming));
790        assert!(negotiated.has_capability(ProtocolCapability::Compression));
791        assert!(!negotiated.has_capability(ProtocolCapability::Attachments));
792    }
793
794    #[test]
795    fn test_remote_agent_info() {
796        let info = RemoteAgentInfo {
797            session_id: "agent-123".to_string(),
798            model: "claude-3-5-sonnet".to_string(),
799            is_busy: false,
800            parent_id: None,
801            working_directory: "/home/user/project".to_string(),
802            message_count: 5,
803            last_activity: 1700000000,
804            status: "idle".to_string(),
805            name: Some("main-agent".to_string()),
806        };
807
808        let json = serde_json::to_string(&info).unwrap();
809        assert!(json.contains("\"session_id\":\"agent-123\""));
810        assert!(json.contains("\"name\":\"main-agent\""));
811    }
812}