use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UsageInfo { pub input_tokens: u64, pub output_tokens: u64, #[serde(default)] pub cache_creation_input_tokens: Option, #[serde(default)] pub cache_read_input_tokens: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(rename_all = "snake_case")] pub enum CharacterState { #[default] Idle, Thinking, Typing, Searching, Coding, Mcp, Permission, Success, Error, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum ConnectionStatus { #[default] Disconnected, Connecting, Connected, Error, } #[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TerminalLine { pub id: String, #[serde(rename = "type")] pub line_type: String, pub content: String, pub timestamp: String, #[serde(skip_serializing_if = "Option::is_none")] pub tool_name: Option, } #[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PermissionRequest { pub id: String, pub tool: String, pub description: String, pub input: serde_json::Value, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PermissionDenial { pub tool_name: String, pub tool_use_id: String, pub tool_input: serde_json::Value, } /// Rate limit information from a `rate_limit_event` message. /// All fields are optional to ensure forward-compatibility as the Claude CLI evolves. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct RateLimitInfo { #[serde(default)] pub requests_limit: Option, #[serde(default)] pub requests_remaining: Option, #[serde(default)] pub requests_reset: Option, #[serde(default)] pub tokens_limit: Option, #[serde(default)] pub tokens_remaining: Option, #[serde(default)] pub tokens_reset: Option, #[serde(default)] pub retry_after_ms: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum ClaudeMessage { #[serde(rename = "system")] System { subtype: String, #[serde(default)] session_id: Option, #[serde(default)] cwd: Option, #[serde(default)] tools: Option>, }, #[serde(rename = "assistant")] Assistant { message: AssistantMessageContent, #[serde(default)] parent_tool_use_id: Option, }, #[serde(rename = "user")] User { message: UserMessageContent }, #[serde(rename = "stream_event")] StreamEvent { event: StreamEventData }, #[serde(rename = "result")] Result { subtype: String, #[serde(default)] result: Option, #[serde(default)] duration_ms: Option, #[serde(default)] num_turns: Option, #[serde(default)] permission_denials: Option>, #[serde(default)] usage: Option, }, #[serde(rename = "rate_limit_event")] RateLimitEvent { #[serde(default)] rate_limit_info: RateLimitInfo, }, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AssistantMessageContent { pub content: Vec, #[serde(default)] pub model: Option, #[serde(default)] pub stop_reason: Option, #[serde(default)] pub usage: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserMessageContent { pub content: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum ContentBlock { #[serde(rename = "text")] Text { text: String }, #[serde(rename = "thinking")] Thinking { thinking: String }, #[serde(rename = "tool_use")] ToolUse { id: String, name: String, input: serde_json::Value, }, #[serde(rename = "tool_result")] ToolResult { tool_use_id: String, content: serde_json::Value, #[serde(default)] is_error: Option, }, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StreamEventData { #[serde(rename = "type")] pub event_type: String, #[serde(default)] pub index: Option, #[serde(default)] pub content_block: Option, #[serde(default)] pub delta: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContentBlockStart { #[serde(rename = "type")] pub block_type: String, #[serde(default)] pub id: Option, #[serde(default)] pub name: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeltaContent { #[serde(rename = "type")] pub delta_type: String, #[serde(default)] pub text: Option, #[serde(default)] pub thinking: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StateChangeEvent { pub state: CharacterState, pub tool_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, } /// Cost information for a message #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MessageCost { pub input_tokens: u64, pub output_tokens: u64, pub cost_usd: f64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OutputEvent { pub line_type: String, pub content: String, pub tool_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cost: Option, #[serde(skip_serializing_if = "Option::is_none")] pub parent_tool_use_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PermissionPromptEventItem { pub id: String, pub tool_name: String, pub tool_input: serde_json::Value, pub description: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PermissionPromptEvent { pub permissions: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConnectionEvent { pub status: ConnectionStatus, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionEvent { pub session_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkingDirectoryEvent { pub directory: String, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QuestionOption { pub label: String, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserQuestionEvent { pub id: String, pub question: String, pub header: Option, pub options: Vec, pub multi_select: bool, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ElicitationEvent { pub message: String, #[serde(skip_serializing_if = "Option::is_none")] pub server_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub request_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ElicitationResultEvent { pub action: String, #[serde(skip_serializing_if = "Option::is_none")] pub request_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StopFailureEvent { #[serde(skip_serializing_if = "Option::is_none")] pub stop_reason: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PostCompactEvent { #[serde(skip_serializing_if = "Option::is_none")] pub session_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CwdChangedEvent { pub cwd: String, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileChangedEvent { pub file: String, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaskCreatedEvent { #[serde(skip_serializing_if = "Option::is_none")] pub task_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] pub parent_tool_use_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PermissionDeniedEvent { #[serde(skip_serializing_if = "Option::is_none")] pub tool_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentStartEvent { pub tool_use_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub agent_id: Option, pub description: String, pub subagent_type: String, pub started_at: u64, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub parent_tool_use_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorktreeInfo { pub name: String, pub path: String, pub branch: String, pub original_repo_directory: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorktreeEvent { #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, /// "create" or "remove" pub event_type: String, #[serde(skip_serializing_if = "Option::is_none")] pub worktree: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentEndEvent { pub tool_use_id: String, pub ended_at: u64, pub is_error: bool, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub duration_ms: Option, #[serde(skip_serializing_if = "Option::is_none")] pub num_turns: Option, #[serde(skip_serializing_if = "Option::is_none")] pub last_assistant_message: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TodoItem { pub content: String, pub status: String, // "pending", "in_progress", or "completed" #[serde(rename = "activeForm")] pub active_form: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TodoUpdateEvent { pub todos: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, } #[cfg(test)] mod tests { use super::*; #[test] fn test_character_state_default() { let state = CharacterState::default(); assert_eq!(state, CharacterState::Idle); } #[test] fn test_connection_status_default() { let status = ConnectionStatus::default(); matches!(status, ConnectionStatus::Disconnected); } #[test] fn test_character_state_serialization() { let state = CharacterState::Thinking; let serialized = serde_json::to_string(&state).unwrap(); assert_eq!(serialized, "\"thinking\""); let deserialized: CharacterState = serde_json::from_str(&serialized).unwrap(); assert_eq!(deserialized, CharacterState::Thinking); } #[test] fn test_all_character_states_serialize() { let states = vec![ (CharacterState::Idle, "\"idle\""), (CharacterState::Thinking, "\"thinking\""), (CharacterState::Typing, "\"typing\""), (CharacterState::Searching, "\"searching\""), (CharacterState::Coding, "\"coding\""), (CharacterState::Mcp, "\"mcp\""), (CharacterState::Permission, "\"permission\""), (CharacterState::Success, "\"success\""), (CharacterState::Error, "\"error\""), ]; for (state, expected) in states { let serialized = serde_json::to_string(&state).unwrap(); assert_eq!(serialized, expected, "Failed for state: {:?}", state); } } #[test] fn test_terminal_line_serialization() { let line = TerminalLine { id: "test-123".to_string(), line_type: "assistant".to_string(), content: "Hello, world!".to_string(), timestamp: "2024-01-01T00:00:00Z".to_string(), tool_name: None, }; let serialized = serde_json::to_string(&line).unwrap(); assert!(serialized.contains("\"type\":\"assistant\"")); assert!(serialized.contains("\"content\":\"Hello, world!\"")); assert!(!serialized.contains("tool_name")); } #[test] fn test_terminal_line_with_tool_name() { let line = TerminalLine { id: "test-456".to_string(), line_type: "tool".to_string(), content: "Reading file...".to_string(), timestamp: "2024-01-01T00:00:00Z".to_string(), tool_name: Some("Read".to_string()), }; let serialized = serde_json::to_string(&line).unwrap(); assert!(serialized.contains("\"tool_name\":\"Read\"")); } #[test] fn test_content_block_text() { let block = ContentBlock::Text { text: "Hello!".to_string(), }; let serialized = serde_json::to_string(&block).unwrap(); assert!(serialized.contains("\"type\":\"text\"")); assert!(serialized.contains("\"text\":\"Hello!\"")); } #[test] fn test_content_block_tool_use() { let block = ContentBlock::ToolUse { id: "tool-123".to_string(), name: "Read".to_string(), input: serde_json::json!({"file_path": "/test.txt"}), }; let serialized = serde_json::to_string(&block).unwrap(); assert!(serialized.contains("\"type\":\"tool_use\"")); assert!(serialized.contains("\"name\":\"Read\"")); } #[test] fn test_state_change_event() { let event = StateChangeEvent { state: CharacterState::Coding, tool_name: Some("Edit".to_string()), conversation_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"state\":\"coding\"")); assert!(serialized.contains("\"tool_name\":\"Edit\"")); } #[test] fn test_output_event() { let event = OutputEvent { line_type: "assistant".to_string(), content: "Test output".to_string(), tool_name: None, conversation_id: None, cost: None, parent_tool_use_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"line_type\":\"assistant\"")); assert!(serialized.contains("\"content\":\"Test output\"")); } #[test] fn test_output_event_with_cost() { let event = OutputEvent { line_type: "assistant".to_string(), content: "Test output".to_string(), tool_name: None, conversation_id: Some("conv-123".to_string()), cost: Some(MessageCost { input_tokens: 100, output_tokens: 50, cost_usd: 0.005, }), parent_tool_use_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"cost\":")); assert!(serialized.contains("\"input_tokens\":100")); assert!(serialized.contains("\"output_tokens\":50")); } #[test] fn test_rate_limit_info_default() { let info = RateLimitInfo::default(); assert!(info.requests_limit.is_none()); assert!(info.requests_remaining.is_none()); assert!(info.requests_reset.is_none()); assert!(info.tokens_limit.is_none()); assert!(info.tokens_remaining.is_none()); assert!(info.tokens_reset.is_none()); assert!(info.retry_after_ms.is_none()); } #[test] fn test_rate_limit_event_deserialization_empty_info() { let json = r#"{"type":"rate_limit_event","rate_limit_info":{}}"#; let msg: ClaudeMessage = serde_json::from_str(json).unwrap(); assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. })); } #[test] fn test_rate_limit_event_deserialization_no_info() { // rate_limit_info field is optional via #[serde(default)] let json = r#"{"type":"rate_limit_event"}"#; let msg: ClaudeMessage = serde_json::from_str(json).unwrap(); assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. })); } #[test] fn test_rate_limit_event_deserialization_with_data() { let json = r#"{ "type": "rate_limit_event", "rate_limit_info": { "requests_limit": 1000, "requests_remaining": 0, "requests_reset": "2024-01-01T00:01:00Z", "tokens_limit": 50000, "tokens_remaining": 0, "tokens_reset": "2024-01-01T00:01:00Z", "retry_after_ms": 60000 } }"#; let msg: ClaudeMessage = serde_json::from_str(json).unwrap(); if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg { assert_eq!(rate_limit_info.requests_limit, Some(1000)); assert_eq!(rate_limit_info.requests_remaining, Some(0)); assert_eq!( rate_limit_info.requests_reset, Some("2024-01-01T00:01:00Z".to_string()) ); assert_eq!(rate_limit_info.retry_after_ms, Some(60000)); } else { panic!("Expected RateLimitEvent variant"); } } #[test] fn test_rate_limit_event_ignores_unknown_fields() { // Ensures forward-compat: unknown fields in rate_limit_info are silently ignored let json = r#"{ "type": "rate_limit_event", "rate_limit_info": { "requests_remaining": 0, "some_future_field": "some_value" } }"#; let msg: ClaudeMessage = serde_json::from_str(json).unwrap(); if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg { assert_eq!(rate_limit_info.requests_remaining, Some(0)); } else { panic!("Expected RateLimitEvent variant"); } } #[test] fn test_elicitation_event_serialization() { let event = ElicitationEvent { message: "Please provide the API endpoint".to_string(), server_name: Some("my-server".to_string()), request_id: Some("req-123".to_string()), conversation_id: Some("conv-abc".to_string()), }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"message\":\"Please provide the API endpoint\"")); assert!(serialized.contains("\"server_name\":\"my-server\"")); assert!(serialized.contains("\"request_id\":\"req-123\"")); assert!(serialized.contains("\"conversation_id\":\"conv-abc\"")); } #[test] fn test_elicitation_event_omits_none_fields() { let event = ElicitationEvent { message: "Enter your token".to_string(), server_name: None, request_id: None, conversation_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"message\":\"Enter your token\"")); assert!(!serialized.contains("server_name")); assert!(!serialized.contains("request_id")); assert!(!serialized.contains("conversation_id")); } #[test] fn test_elicitation_result_event_serialization() { let event = ElicitationResultEvent { action: "accept".to_string(), request_id: Some("req-123".to_string()), conversation_id: Some("conv-abc".to_string()), }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"action\":\"accept\"")); assert!(serialized.contains("\"request_id\":\"req-123\"")); } #[test] fn test_elicitation_result_event_cancel_omits_none_fields() { let event = ElicitationResultEvent { action: "cancel".to_string(), request_id: None, conversation_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"action\":\"cancel\"")); assert!(!serialized.contains("request_id")); assert!(!serialized.contains("conversation_id")); } #[test] fn test_stop_failure_event_serialization() { let event = StopFailureEvent { stop_reason: Some("api_error".to_string()), error_type: Some("rate_limit".to_string()), conversation_id: Some("conv-abc".to_string()), }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"stop_reason\":\"api_error\"")); assert!(serialized.contains("\"error_type\":\"rate_limit\"")); assert!(serialized.contains("\"conversation_id\":\"conv-abc\"")); } #[test] fn test_stop_failure_event_omits_none_fields() { let event = StopFailureEvent { stop_reason: None, error_type: None, conversation_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); assert!(!serialized.contains("stop_reason")); assert!(!serialized.contains("error_type")); assert!(!serialized.contains("conversation_id")); } #[test] fn test_stop_failure_event_partial_fields() { let event = StopFailureEvent { stop_reason: Some("api_error".to_string()), error_type: None, conversation_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"stop_reason\":\"api_error\"")); assert!(!serialized.contains("error_type")); assert!(!serialized.contains("conversation_id")); } #[test] fn test_post_compact_event_serialization() { let event = PostCompactEvent { session_id: Some("sess-abc".to_string()), conversation_id: Some("conv-123".to_string()), }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"session_id\":\"sess-abc\"")); assert!(serialized.contains("\"conversation_id\":\"conv-123\"")); } #[test] fn test_post_compact_event_omits_none_fields() { let event = PostCompactEvent { session_id: None, conversation_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); assert!(!serialized.contains("session_id")); assert!(!serialized.contains("conversation_id")); } #[test] fn test_post_compact_event_partial_fields() { let event = PostCompactEvent { session_id: Some("sess-xyz".to_string()), conversation_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"session_id\":\"sess-xyz\"")); assert!(!serialized.contains("conversation_id")); } #[test] fn test_cwd_changed_event_serialization() { let event = CwdChangedEvent { cwd: "/home/naomi/code/my-project".to_string(), conversation_id: Some("conv-abc".to_string()), }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"cwd\":\"/home/naomi/code/my-project\"")); assert!(serialized.contains("\"conversation_id\":\"conv-abc\"")); } #[test] fn test_cwd_changed_event_omits_none_fields() { let event = CwdChangedEvent { cwd: "/tmp/workspace".to_string(), conversation_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"cwd\":\"/tmp/workspace\"")); assert!(!serialized.contains("conversation_id")); } #[test] fn test_file_changed_event_serialization() { let event = FileChangedEvent { file: "/home/naomi/code/my-project/src/main.rs".to_string(), conversation_id: Some("conv-abc".to_string()), }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"file\":\"/home/naomi/code/my-project/src/main.rs\"")); assert!(serialized.contains("\"conversation_id\":\"conv-abc\"")); } #[test] fn test_file_changed_event_omits_none_fields() { let event = FileChangedEvent { file: "/tmp/test.txt".to_string(), conversation_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"file\":\"/tmp/test.txt\"")); assert!(!serialized.contains("conversation_id")); } #[test] fn test_task_created_event_serialization() { let event = TaskCreatedEvent { task_id: Some("task-abc123".to_string()), description: Some("Explore the codebase".to_string()), parent_tool_use_id: Some("toolu_xyz".to_string()), conversation_id: Some("conv-abc".to_string()), }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"task_id\":\"task-abc123\"")); assert!(serialized.contains("\"description\":\"Explore the codebase\"")); assert!(serialized.contains("\"parent_tool_use_id\":\"toolu_xyz\"")); assert!(serialized.contains("\"conversation_id\":\"conv-abc\"")); } #[test] fn test_task_created_event_omits_none_fields() { let event = TaskCreatedEvent { task_id: None, description: None, parent_tool_use_id: None, conversation_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); assert_eq!(serialized, "{}"); } #[test] fn test_task_created_event_partial_fields() { let event = TaskCreatedEvent { task_id: Some("task-001".to_string()), description: None, parent_tool_use_id: None, conversation_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"task_id\":\"task-001\"")); assert!(!serialized.contains("description")); assert!(!serialized.contains("parent_tool_use_id")); assert!(!serialized.contains("conversation_id")); } #[test] fn test_permission_denied_event_serialization() { let event = PermissionDeniedEvent { tool_name: Some("Bash".to_string()), reason: Some("Tool not in allow list".to_string()), conversation_id: Some("conv-abc".to_string()), }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"tool_name\":\"Bash\"")); assert!(serialized.contains("\"reason\":\"Tool not in allow list\"")); assert!(serialized.contains("\"conversation_id\":\"conv-abc\"")); } #[test] fn test_permission_denied_event_omits_none_fields() { let event = PermissionDeniedEvent { tool_name: None, reason: None, conversation_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); assert_eq!(serialized, "{}"); } #[test] fn test_permission_denied_event_partial_fields() { let event = PermissionDeniedEvent { tool_name: Some("Edit".to_string()), reason: None, conversation_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); assert!(serialized.contains("\"tool_name\":\"Edit\"")); assert!(!serialized.contains("reason")); assert!(!serialized.contains("conversation_id")); } }