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}