use crate::types::meta::Meta; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Session { pub id: String, pub title: String, pub created_at: DateTime, pub updated_at: DateTime, pub metadata: SessionMetadata, /// Working directory for this session (if known) #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, /// ACP model state (available models and current model) /// Populated from archivist for archived sessions, or from SSE events for live sessions. #[serde(default, skip_serializing_if = "Option::is_none")] pub models: Option, /// ACP mode state (available modes and current mode) /// Populated from archivist for archived sessions, or from SSE events for live sessions. #[serde(default, skip_serializing_if = "Option::is_none")] pub modes: Option, /// ACP config options (replaces modes/models in future ACP versions) #[serde(default, skip_serializing_if = "Option::is_none")] pub config_options: Option>, /// ACP client ID that owns this session. /// For sessions created via ACP Server (incoming connections), this identifies /// which connected client created/owns this session. #[serde(default, skip_serializing_if = "Option::is_none")] pub acp_client_id: Option, } // ============================================================================ // ACP Session Mode/Model Types // ============================================================================ // These types match the Agent-Client Protocol (ACP) specification exactly. // They use camelCase serialization to match Claude-ACP's JSON format. // See: docs/architecture/agent_client_protocol/schema.md /// Type alias for session mode identifiers (e.g., "default", "plan", "bypassPermissions") pub type SessionModeId = String; /// Type alias for model identifiers (e.g., "default", "sonnet", "haiku", "opus") pub type ModelId = String; /// Session mode state from ACP `session/new` response /// /// Contains the list of available modes and the currently active mode. /// This is part of the stable ACP specification. /// /// # Example (from Claude-ACP) /// ```json /// { /// "currentModeId": "default", /// "availableModes": [ /// {"id": "default", "name": "Always Ask", "description": "..."}, /// {"id": "plan", "name": "Plan Mode", "description": "..."} /// ] /// } /// ``` #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SessionModeState { /// The currently active mode ID pub current_mode_id: SessionModeId, /// List of all available modes for this session pub available_modes: Vec, } /// A single session mode definition /// /// Modes affect agent behavior, tool availability, and permission handling. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SessionMode { /// Unique identifier for this mode pub id: SessionModeId, /// Human-readable display name pub name: String, /// Optional description of what this mode does #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, } /// Session model state from ACP `session/new` response /// /// Contains the list of available models and the currently selected model. /// Note: This field is marked UNSTABLE in the ACP spec but is used by Claude-ACP. /// /// # Example (from Claude-ACP) /// ```json /// { /// "availableModels": [ /// {"modelId": "default", "name": "Default (recommended)", "description": "..."}, /// {"modelId": "sonnet", "name": "Sonnet", "description": "..."} /// ], /// "currentModelId": "default" /// } /// ``` #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SessionModelState { /// List of all available models for this session pub available_models: Vec, /// The currently selected model ID pub current_model_id: ModelId, } /// Information about a single model #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ModelInfo { /// Unique identifier for this model pub model_id: ModelId, /// Human-readable display name pub name: String, /// Optional description of the model's capabilities #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, } // ============================================================================ // ACP Config Options (replaces modes/models in future ACP versions) // ============================================================================ /// A configuration option for a session (ACP configOptions). /// /// Agents provide config options in session/new and session/load responses. /// Clients should use these instead of the legacy `modes`/`models` fields. /// See: docs/architecture/agent_client_protocol/session-config-options.md #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ConfigOption { /// Unique identifier (e.g., "mode", "model") pub id: String, /// Human-readable label pub name: String, /// Optional description #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, /// Semantic category for UX grouping (e.g., "mode", "model", "thought_level") #[serde(skip_serializing_if = "Option::is_none")] pub category: Option, /// Input type (currently only "select" is defined) #[serde(rename = "type")] pub option_type: ConfigOptionType, /// Currently selected value pub current_value: String, /// Available values for select-type options pub options: Vec, } /// Type of configuration option input #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum ConfigOptionType { Select, } /// A single value choice within a config option #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ConfigOptionValue { /// Value identifier (sent back when setting this option) pub value: String, /// Human-readable display name pub name: String, /// Optional description #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct SessionMetadata { pub project_path: String, pub model: Option, /// Total count of user and assistant messages in the session (excludes system messages). /// A value of 0 may indicate either an empty session or that the count has not yet been calculated. /// Counts are populated lazily when messages are loaded for a session. /// See `docs/architecture/session_message_counts.md` for details. pub total_messages: u32, #[serde(skip_serializing_if = "Option::is_none")] pub system_message: Option, /// Current mode identifier for future mode tracking #[serde(skip_serializing_if = "Option::is_none")] pub current_mode_id: Option, /// Provider metadata for tracking original IDs and debugging information #[serde(skip_serializing_if = "Option::is_none")] pub _meta: Option, /// Optional project ID linking this session to a dirigent_projects Project. /// When set, the session belongs to the specified project for organizational purposes. #[serde(default, skip_serializing_if = "Option::is_none")] pub project_id: Option, } // ============================================================================ // Session Ownership Model // ============================================================================ // These types define how sessions are owned and how tool execution is routed. // See: docs/architecture/session_ownership.md (Phase 7) /// The origin of a session - who initiated it #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum SessionOrigin { /// Session created by Dirigent UI user Internal, /// Session forwarded from external ACP client External { /// The ACP client ID that owns this session client_id: String, /// Cached client capabilities (from initialization) #[serde(default, skip_serializing_if = "Option::is_none")] client_capabilities: Option, }, /// Session representing a subagent or internal task (future) Subagent { /// Parent session that spawned this subagent parent_session_id: String, /// Task identifier task_id: String, }, } impl Default for SessionOrigin { fn default() -> Self { Self::Internal } } /// Who handles tool execution for this session #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum ToolHandler { /// Agent handles its own tools (default) #[default] Agent, /// Dirigent intercepts and handles tools via dirigent_tools (future) Dirigent, /// Forward tool requests to originating client (External sessions only) ForwardToClient, } /// Complete ownership model for a session #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct SessionOwnership { /// Where this session originated #[serde(default)] pub origin: SessionOrigin, /// How tool requests are handled #[serde(default)] pub tool_handler: ToolHandler, } impl SessionOwnership { /// Internal session with agent handling tools (default UI case) pub fn internal() -> Self { Self { origin: SessionOrigin::Internal, tool_handler: ToolHandler::Agent, } } /// External session with tools forwarded to client pub fn external_forwarded(client_id: String, capabilities: Option) -> Self { Self { origin: SessionOrigin::External { client_id, client_capabilities: capabilities, }, tool_handler: ToolHandler::ForwardToClient, } } /// External session but Dirigent handles tools pub fn external_handled(client_id: String, capabilities: Option) -> Self { Self { origin: SessionOrigin::External { client_id, client_capabilities: capabilities, }, tool_handler: ToolHandler::Dirigent, } } /// Get capabilities to advertise to agent based on ownership pub fn capabilities_for_agent(&self) -> serde_json::Value { match (&self.origin, &self.tool_handler) { // External + ForwardToClient: use client's capabilities ( SessionOrigin::External { client_capabilities: Some(caps), .. }, ToolHandler::ForwardToClient, ) => caps.clone(), // Dirigent handles tools: advertise dirigent_tools capabilities (_, ToolHandler::Dirigent) => { serde_json::json!({ "fs": { "readTextFile": true, "writeTextFile": true }, "terminal": true }) } // Agent handles tools or no client caps: empty (agent uses its own) _ => serde_json::json!({}), } } /// Get the client ID if this should forward requests to a client pub fn forward_to_client(&self) -> Option<&str> { match (&self.origin, &self.tool_handler) { (SessionOrigin::External { client_id, .. }, ToolHandler::ForwardToClient) => { Some(client_id.as_str()) } _ => None, } } /// Check if this is an external (forwarded) session pub fn is_external(&self) -> bool { matches!(self.origin, SessionOrigin::External { .. }) } /// Get the originating client ID if external pub fn client_id(&self) -> Option<&str> { match &self.origin { SessionOrigin::External { client_id, .. } => Some(client_id.as_str()), _ => None, } } } #[cfg(test)] mod tests { use super::*; use crate::types::meta::{Meta, ProviderMeta}; use std::collections::HashMap; // ======================================================================== // ACP Session Mode/Model Type Tests // ======================================================================== #[test] fn test_session_mode_state_serialization_camel_case() { // Verify camelCase serialization matches Claude-ACP format let mode_state = SessionModeState { current_mode_id: "default".to_string(), available_modes: vec![ SessionMode { id: "default".to_string(), name: "Always Ask".to_string(), description: Some( "Prompts for permission on first use of each tool".to_string(), ), }, SessionMode { id: "plan".to_string(), name: "Plan Mode".to_string(), description: Some("Claude can analyze but not modify files".to_string()), }, ], }; let json = serde_json::to_string(&mode_state).unwrap(); // Verify camelCase field names assert!(json.contains("currentModeId")); assert!(json.contains("availableModes")); // Verify content assert!(json.contains(r#""currentModeId":"default"#)); assert!(json.contains(r#""name":"Always Ask"#)); } #[test] fn test_session_mode_state_deserialization_from_claude_format() { // Test deserialization of actual Claude-ACP format let json = r#"{ "currentModeId": "default", "availableModes": [ { "id": "default", "name": "Always Ask", "description": "Prompts for permission on first use of each tool" }, { "id": "acceptEdits", "name": "Accept Edits", "description": "Automatically accepts file edit permissions for the session" }, { "id": "plan", "name": "Plan Mode", "description": "Claude can analyze but not modify files or execute commands" }, { "id": "bypassPermissions", "name": "Bypass Permissions", "description": "Skips all permission prompts" } ] }"#; let mode_state: SessionModeState = serde_json::from_str(json).unwrap(); assert_eq!(mode_state.current_mode_id, "default"); assert_eq!(mode_state.available_modes.len(), 4); assert_eq!(mode_state.available_modes[0].id, "default"); assert_eq!(mode_state.available_modes[0].name, "Always Ask"); assert_eq!(mode_state.available_modes[3].id, "bypassPermissions"); } #[test] fn test_session_mode_state_roundtrip() { let original = SessionModeState { current_mode_id: "plan".to_string(), available_modes: vec![ SessionMode { id: "default".to_string(), name: "Default".to_string(), description: None, }, SessionMode { id: "plan".to_string(), name: "Plan".to_string(), description: Some("Planning mode".to_string()), }, ], }; let json = serde_json::to_string(&original).unwrap(); let deserialized: SessionModeState = serde_json::from_str(&json).unwrap(); assert_eq!(original, deserialized); } #[test] fn test_session_mode_skip_none_description() { // Test that None descriptions are not serialized let mode = SessionMode { id: "test".to_string(), name: "Test".to_string(), description: None, }; let json = serde_json::to_string(&mode).unwrap(); assert!(!json.contains("description")); } #[test] fn test_session_model_state_serialization_camel_case() { // Verify camelCase serialization matches Claude-ACP format let model_state = SessionModelState { available_models: vec![ ModelInfo { model_id: "default".to_string(), name: "Default (recommended)".to_string(), description: Some("Opus 4.5 · Most capable for complex work".to_string()), }, ModelInfo { model_id: "sonnet".to_string(), name: "Sonnet".to_string(), description: Some("Sonnet 4.5 · Best for everyday tasks".to_string()), }, ], current_model_id: "default".to_string(), }; let json = serde_json::to_string(&model_state).unwrap(); // Verify camelCase field names assert!(json.contains("availableModels")); assert!(json.contains("currentModelId")); assert!(json.contains("modelId")); // Verify content assert!(json.contains(r#""currentModelId":"default"#)); assert!(json.contains(r#""name":"Sonnet"#)); } #[test] fn test_session_model_state_deserialization_from_claude_format() { // Test deserialization of actual Claude-ACP format (from zed_claude_code_direct_acp_log.txt) let json = r#"{ "availableModels": [ { "modelId": "default", "name": "Default (recommended)", "description": "Opus 4.5 · Most capable for complex work" }, { "modelId": "sonnet", "name": "Sonnet", "description": "Sonnet 4.5 · Best for everyday tasks" }, { "modelId": "haiku", "name": "Haiku", "description": "Haiku 4.5 · Fastest for quick answers" }, { "modelId": "opus", "name": "opus", "description": "Custom model" } ], "currentModelId": "default" }"#; let model_state: SessionModelState = serde_json::from_str(json).unwrap(); assert_eq!(model_state.current_model_id, "default"); assert_eq!(model_state.available_models.len(), 4); assert_eq!(model_state.available_models[0].model_id, "default"); assert_eq!( model_state.available_models[0].name, "Default (recommended)" ); assert_eq!(model_state.available_models[2].model_id, "haiku"); assert_eq!(model_state.available_models[3].model_id, "opus"); } #[test] fn test_session_model_state_roundtrip() { let original = SessionModelState { available_models: vec![ModelInfo { model_id: "default".to_string(), name: "Default".to_string(), description: None, }], current_model_id: "default".to_string(), }; let json = serde_json::to_string(&original).unwrap(); let deserialized: SessionModelState = serde_json::from_str(&json).unwrap(); assert_eq!(original, deserialized); } #[test] fn test_model_info_skip_none_description() { // Test that None descriptions are not serialized let model = ModelInfo { model_id: "test".to_string(), name: "Test".to_string(), description: None, }; let json = serde_json::to_string(&model).unwrap(); assert!(!json.contains("description")); } // ======================================================================== // SessionMetadata Tests (existing) // ======================================================================== #[test] fn test_session_metadata_backward_compatibility() { // Test that existing SessionMetadata without new fields can be deserialized let json = r#"{ "project_path": "/test/path", "model": "gpt-4", "total_messages": 10, "system_message": "System prompt" }"#; let metadata: SessionMetadata = serde_json::from_str(json).unwrap(); assert_eq!(metadata.project_path, "/test/path"); assert_eq!(metadata.model, Some("gpt-4".to_string())); assert_eq!(metadata.total_messages, 10); assert_eq!(metadata.system_message, Some("System prompt".to_string())); assert_eq!(metadata.current_mode_id, None); assert_eq!(metadata._meta, None); } #[test] fn test_session_metadata_skip_serializing_none() { // Test that None values are skipped during serialization let metadata = SessionMetadata { project_path: "/test".to_string(), model: Some("gpt-4".to_string()), total_messages: 0, system_message: None, current_mode_id: None, _meta: None, project_id: None, }; let json = serde_json::to_string(&metadata).unwrap(); // Should not contain system_message, current_mode_id, or _meta fields assert!(!json.contains("system_message")); assert!(!json.contains("current_mode_id")); assert!(!json.contains("_meta")); // Should contain the present fields assert!(json.contains("project_path")); assert!(json.contains("model")); assert!(json.contains("total_messages")); } #[test] fn test_session_metadata_with_current_mode_id() { // Test serialization/deserialization with current_mode_id let metadata = SessionMetadata { project_path: "/test".to_string(), model: Some("gpt-4".to_string()), total_messages: 5, system_message: None, current_mode_id: Some("code_mode".to_string()), _meta: None, project_id: None, }; let json = serde_json::to_string(&metadata).unwrap(); assert!(json.contains("current_mode_id")); assert!(json.contains("code_mode")); let deserialized: SessionMetadata = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.current_mode_id, Some("code_mode".to_string())); } #[test] fn test_session_metadata_with_meta() { // Test serialization/deserialization with provider metadata let meta = Meta { provider: Some(ProviderMeta { name: "opencode".to_string(), original_ids: Some(HashMap::from([( "session_id".to_string(), "ses_abc123".to_string(), )])), raw_excerpt: None, }), extra: HashMap::new(), }; let metadata = SessionMetadata { project_path: "/test".to_string(), model: Some("gpt-4".to_string()), total_messages: 5, system_message: None, current_mode_id: None, _meta: Some(meta), project_id: None, }; let json = serde_json::to_string(&metadata).unwrap(); assert!(json.contains("_meta")); assert!(json.contains("opencode")); assert!(json.contains("ses_abc123")); let deserialized: SessionMetadata = serde_json::from_str(&json).unwrap(); assert!(deserialized._meta.is_some()); let deserialized_meta = deserialized._meta.unwrap(); assert!(deserialized_meta.provider.is_some()); assert_eq!(deserialized_meta.provider.unwrap().name, "opencode"); } #[test] fn test_session_metadata_with_all_fields() { // Test with all fields populated let meta = Meta { provider: Some(ProviderMeta { name: "anthropic".to_string(), original_ids: Some(HashMap::from([( "conversation_id".to_string(), "conv_xyz".to_string(), )])), raw_excerpt: Some(serde_json::json!({"version": "1.0"})), }), extra: HashMap::new(), }; let metadata = SessionMetadata { project_path: "/project".to_string(), model: Some("claude-3".to_string()), total_messages: 42, system_message: Some("Be helpful".to_string()), current_mode_id: Some("architect".to_string()), _meta: Some(meta), project_id: None, }; let json = serde_json::to_string(&metadata).unwrap(); let deserialized: SessionMetadata = serde_json::from_str(&json).unwrap(); assert_eq!(metadata, deserialized); assert!(json.contains("system_message")); assert!(json.contains("current_mode_id")); assert!(json.contains("_meta")); } #[test] fn test_session_roundtrip_with_new_fields() { // Test that a Session with new metadata fields survives roundtrip let now = Utc::now(); let session = Session { id: "ses_test123".to_string(), title: "Test Session".to_string(), created_at: now, updated_at: now, metadata: SessionMetadata { project_path: "/workspace".to_string(), model: Some("gpt-4-turbo".to_string()), total_messages: 7, system_message: Some("You are a coding assistant".to_string()), current_mode_id: Some("debug_mode".to_string()), _meta: Some(Meta { provider: Some(ProviderMeta { name: "test_provider".to_string(), original_ids: None, raw_excerpt: None, }), extra: HashMap::new(), }), project_id: None, }, cwd: None, models: None, modes: None, config_options: None, acp_client_id: None, }; let json = serde_json::to_string(&session).unwrap(); let deserialized: Session = serde_json::from_str(&json).unwrap(); assert_eq!(session, deserialized); } #[test] fn test_session_with_models_and_modes() { // Test Session with models and modes populated let now = Utc::now(); let session = Session { id: "ses_test456".to_string(), title: "Test Session with ACP".to_string(), created_at: now, updated_at: now, metadata: SessionMetadata { project_path: "/workspace".to_string(), model: Some("default".to_string()), total_messages: 5, system_message: None, current_mode_id: Some("default".to_string()), _meta: None, project_id: None, }, cwd: None, models: Some(SessionModelState { available_models: vec![ ModelInfo { model_id: "default".to_string(), name: "Default".to_string(), description: Some("Default model".to_string()), }, ModelInfo { model_id: "sonnet".to_string(), name: "Sonnet".to_string(), description: None, }, ], current_model_id: "default".to_string(), }), modes: Some(SessionModeState { current_mode_id: "default".to_string(), available_modes: vec![SessionMode { id: "default".to_string(), name: "Always Ask".to_string(), description: None, }], }), config_options: None, acp_client_id: Some("test-client-123".to_string()), }; let json = serde_json::to_string(&session).unwrap(); assert!(json.contains("models")); assert!(json.contains("modes")); assert!(json.contains("acp_client_id")); assert!(json.contains("availableModels")); assert!(json.contains("currentModeId")); let deserialized: Session = serde_json::from_str(&json).unwrap(); assert_eq!(session, deserialized); } #[test] fn test_session_backward_compatibility_no_models_modes() { // Test that old Session JSON without models/modes can be deserialized let json = r#"{ "id": "ses_old", "title": "Old Session", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", "metadata": { "project_path": "/test", "model": "gpt-4", "total_messages": 10 } }"#; let session: Session = serde_json::from_str(json).unwrap(); assert_eq!(session.id, "ses_old"); assert!(session.models.is_none()); assert!(session.modes.is_none()); } // ======================================================================== // Session Ownership Model Tests // ======================================================================== #[test] fn test_session_origin_default() { // Verify Internal is the default let origin = SessionOrigin::default(); assert_eq!(origin, SessionOrigin::Internal); } #[test] fn test_session_origin_serialization() { // Test Internal variant let internal = SessionOrigin::Internal; let json = serde_json::to_string(&internal).unwrap(); assert!(json.contains(r#""type":"internal"#)); // Test External variant let external = SessionOrigin::External { client_id: "client-123".to_string(), client_capabilities: Some(serde_json::json!({"tools": ["bash"]})), }; let json = serde_json::to_string(&external).unwrap(); assert!(json.contains(r#""type":"external"#)); assert!(json.contains(r#""client_id":"client-123"#)); assert!(json.contains("tools")); // Test Subagent variant let subagent = SessionOrigin::Subagent { parent_session_id: "parent-456".to_string(), task_id: "task-789".to_string(), }; let json = serde_json::to_string(&subagent).unwrap(); assert!(json.contains(r#""type":"subagent"#)); assert!(json.contains(r#""parent_session_id":"parent-456"#)); assert!(json.contains(r#""task_id":"task-789"#)); } #[test] fn test_tool_handler_default() { // Verify Agent is the default let handler = ToolHandler::default(); assert_eq!(handler, ToolHandler::Agent); } #[test] fn test_tool_handler_serialization() { let agent = ToolHandler::Agent; let json = serde_json::to_string(&agent).unwrap(); assert_eq!(json, r#""agent""#); let dirigent = ToolHandler::Dirigent; let json = serde_json::to_string(&dirigent).unwrap(); assert_eq!(json, r#""dirigent""#); let forward = ToolHandler::ForwardToClient; let json = serde_json::to_string(&forward).unwrap(); assert_eq!(json, r#""forward_to_client""#); } #[test] fn test_session_ownership_internal() { let ownership = SessionOwnership::internal(); assert_eq!(ownership.origin, SessionOrigin::Internal); assert_eq!(ownership.tool_handler, ToolHandler::Agent); assert!(!ownership.is_external()); assert_eq!(ownership.client_id(), None); assert_eq!(ownership.forward_to_client(), None); } #[test] fn test_session_ownership_external_forwarded() { let caps = serde_json::json!({"tools": ["bash", "edit"]}); let ownership = SessionOwnership::external_forwarded("client-123".to_string(), Some(caps.clone())); match &ownership.origin { SessionOrigin::External { client_id, client_capabilities, } => { assert_eq!(client_id, "client-123"); assert_eq!(client_capabilities.as_ref().unwrap(), &caps); } _ => panic!("Expected External origin"), } assert_eq!(ownership.tool_handler, ToolHandler::ForwardToClient); assert!(ownership.is_external()); assert_eq!(ownership.client_id(), Some("client-123")); assert_eq!(ownership.forward_to_client(), Some("client-123")); } #[test] fn test_session_ownership_external_handled() { let ownership = SessionOwnership::external_handled("client-456".to_string(), None); match &ownership.origin { SessionOrigin::External { client_id, client_capabilities, } => { assert_eq!(client_id, "client-456"); assert!(client_capabilities.is_none()); } _ => panic!("Expected External origin"), } assert_eq!(ownership.tool_handler, ToolHandler::Dirigent); assert!(ownership.is_external()); assert_eq!(ownership.client_id(), Some("client-456")); assert_eq!(ownership.forward_to_client(), None); // Dirigent handles, not forwarded } #[test] fn test_capabilities_for_agent_external_forwarded() { let client_caps = serde_json::json!({"fs": true, "terminal": true}); let ownership = SessionOwnership::external_forwarded( "client-123".to_string(), Some(client_caps.clone()), ); let caps = ownership.capabilities_for_agent(); assert_eq!(caps, client_caps); } #[test] fn test_capabilities_for_agent_dirigent() { let ownership = SessionOwnership { origin: SessionOrigin::Internal, tool_handler: ToolHandler::Dirigent, }; let caps = ownership.capabilities_for_agent(); assert!(caps.is_object()); assert!(caps.get("fs").is_some()); assert!(caps.get("terminal").is_some()); } #[test] fn test_capabilities_for_agent_agent_handled() { let ownership = SessionOwnership::internal(); let caps = ownership.capabilities_for_agent(); assert_eq!(caps, serde_json::json!({})); } #[test] fn test_session_ownership_serialization_roundtrip() { let original = SessionOwnership::external_forwarded( "test-client".to_string(), Some(serde_json::json!({"test": true})), ); let json = serde_json::to_string(&original).unwrap(); let deserialized: SessionOwnership = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.tool_handler, original.tool_handler); assert_eq!(deserialized.client_id(), Some("test-client")); } #[test] fn test_session_ownership_default() { let ownership = SessionOwnership::default(); assert_eq!(ownership.origin, SessionOrigin::Internal); assert_eq!(ownership.tool_handler, ToolHandler::Agent); } }