generated from nhcarrigan/template
1c4432c4d8
## Overview Full audit and implementation of Claude Code CLI changelog entries from v2.1.105 through v2.1.131. ## Changes ### Implemented - **#268** — Add `claude-opus-4-7` to the model picker, update all model pricing and context window sizes (also fixes a bug where `claude-opus-4-6` was coded as 200K context instead of 1M) - **#269** — Expose effort level setting in UI via `--effort <level>` flag (`low`, `medium`, `high`, `xhigh`, `max`) - **#273** — Expose prompt caching TTL env vars in UI (`ENABLE_PROMPT_CACHING_1H` / `FORCE_PROMPT_CACHING_5M`) - **#267** — Add `PreCompact` hook support — emits `claude:pre-compact` event with "Compacting context..." toast and thinking state - **#270** — Parse `plugin_errors` from stream-json init event and surface them as error output lines - **#266** — Bump supported CLI version constant to `2.1.131` ### Closed as Not Applicable - **#271** (`autoScrollEnabled`) — TUI-only setting; we manage our own scroll behaviour - **#272** (`tui` fullscreen mode) — TUI-only setting; we use stream-json and never activate the TUI ## Testing All checks pass (`./check-all.sh`) including frontend lint, format, type check, Vitest coverage, Clippy, and Rust test coverage. ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #274 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
1063 lines
35 KiB
Rust
1063 lines
35 KiB
Rust
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<u64>,
|
|
#[serde(default)]
|
|
pub cache_read_input_tokens: Option<u64>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
#[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<u64>,
|
|
#[serde(default)]
|
|
pub requests_remaining: Option<u64>,
|
|
#[serde(default)]
|
|
pub requests_reset: Option<String>,
|
|
#[serde(default)]
|
|
pub tokens_limit: Option<u64>,
|
|
#[serde(default)]
|
|
pub tokens_remaining: Option<u64>,
|
|
#[serde(default)]
|
|
pub tokens_reset: Option<String>,
|
|
#[serde(default)]
|
|
pub retry_after_ms: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type")]
|
|
pub enum ClaudeMessage {
|
|
#[serde(rename = "system")]
|
|
System {
|
|
subtype: String,
|
|
#[serde(default)]
|
|
session_id: Option<String>,
|
|
#[serde(default)]
|
|
cwd: Option<String>,
|
|
#[serde(default)]
|
|
tools: Option<Vec<String>>,
|
|
/// Output style hint from Claude Code (v2.1.81+). Informational only.
|
|
#[serde(default)]
|
|
output_style: Option<String>,
|
|
/// Plugin errors from Claude Code (v2.1.111+). Populated when plugins are demoted
|
|
/// due to unsatisfied dependencies.
|
|
#[serde(default)]
|
|
plugin_errors: Option<serde_json::Value>,
|
|
},
|
|
#[serde(rename = "assistant")]
|
|
Assistant {
|
|
message: AssistantMessageContent,
|
|
#[serde(default)]
|
|
parent_tool_use_id: Option<String>,
|
|
},
|
|
#[serde(rename = "user")]
|
|
User { message: UserMessageContent },
|
|
#[serde(rename = "stream_event")]
|
|
StreamEvent { event: StreamEventData },
|
|
#[serde(rename = "result")]
|
|
Result {
|
|
subtype: String,
|
|
#[serde(default)]
|
|
result: Option<String>,
|
|
#[serde(default)]
|
|
duration_ms: Option<u64>,
|
|
#[serde(default)]
|
|
num_turns: Option<u32>,
|
|
#[serde(default)]
|
|
permission_denials: Option<Vec<PermissionDenial>>,
|
|
#[serde(default)]
|
|
usage: Option<UsageInfo>,
|
|
/// Fast mode state from Claude Code v2.1.81+. Values: "default" | "enabled" | "disabled".
|
|
#[serde(default)]
|
|
fast_mode_state: Option<String>,
|
|
/// Per-model usage breakdown from Claude Code v2.1.81+.
|
|
#[serde(default)]
|
|
model_usage: Option<serde_json::Value>,
|
|
/// Authoritative total cost in USD reported by Claude Code v2.1.81+.
|
|
#[serde(default)]
|
|
total_cost_usd: Option<f64>,
|
|
},
|
|
#[serde(rename = "rate_limit_event")]
|
|
RateLimitEvent {
|
|
#[serde(default)]
|
|
rate_limit_info: RateLimitInfo,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AssistantMessageContent {
|
|
pub content: Vec<ContentBlock>,
|
|
#[serde(default)]
|
|
pub model: Option<String>,
|
|
#[serde(default)]
|
|
pub stop_reason: Option<String>,
|
|
#[serde(default)]
|
|
pub usage: Option<UsageInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct UserMessageContent {
|
|
pub content: Vec<ContentBlock>,
|
|
}
|
|
|
|
#[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<bool>,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct StreamEventData {
|
|
#[serde(rename = "type")]
|
|
pub event_type: String,
|
|
#[serde(default)]
|
|
pub index: Option<u32>,
|
|
#[serde(default)]
|
|
pub content_block: Option<ContentBlockStart>,
|
|
#[serde(default)]
|
|
pub delta: Option<DeltaContent>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ContentBlockStart {
|
|
#[serde(rename = "type")]
|
|
pub block_type: String,
|
|
#[serde(default)]
|
|
pub id: Option<String>,
|
|
#[serde(default)]
|
|
pub name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DeltaContent {
|
|
#[serde(rename = "type")]
|
|
pub delta_type: String,
|
|
#[serde(default)]
|
|
pub text: Option<String>,
|
|
#[serde(default)]
|
|
pub thinking: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct StateChangeEvent {
|
|
pub state: CharacterState,
|
|
pub tool_name: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub cost: Option<MessageCost>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub parent_tool_use_id: Option<String>,
|
|
}
|
|
|
|
#[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<PermissionPromptEventItem>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ConnectionEvent {
|
|
pub status: ConnectionStatus,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SessionEvent {
|
|
pub session_id: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct WorkingDirectoryEvent {
|
|
pub directory: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct QuestionOption {
|
|
pub label: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct UserQuestionEvent {
|
|
pub id: String,
|
|
pub question: String,
|
|
pub header: Option<String>,
|
|
pub options: Vec<QuestionOption>,
|
|
pub multi_select: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ElicitationEvent {
|
|
pub message: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub server_name: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub request_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ElicitationResultEvent {
|
|
pub action: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub request_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct StopFailureEvent {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub stop_reason: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub error_type: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PostCompactEvent {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub session_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PreCompactEvent {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub session_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CwdChangedEvent {
|
|
pub cwd: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct FileChangedEvent {
|
|
pub file: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TaskCreatedEvent {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub task_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub parent_tool_use_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PermissionDeniedEvent {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub tool_name: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub reason: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AgentStartEvent {
|
|
pub tool_use_id: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub agent_id: Option<String>,
|
|
pub description: String,
|
|
pub subagent_type: String,
|
|
pub started_at: u64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub parent_tool_use_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub model: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
/// "create" or "remove"
|
|
pub event_type: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub worktree: Option<WorktreeInfo>,
|
|
}
|
|
|
|
#[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<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub duration_ms: Option<u64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub num_turns: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub last_assistant_message: Option<String>,
|
|
}
|
|
|
|
#[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<TodoItem>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[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_pre_compact_event_serialization() {
|
|
let event = PreCompactEvent {
|
|
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_pre_compact_event_omits_none_fields() {
|
|
let event = PreCompactEvent {
|
|
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_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"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_system_init_with_output_style() {
|
|
let json = r#"{"type":"system","subtype":"init","session_id":"sess-1","output_style":"auto"}"#;
|
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
|
if let ClaudeMessage::System { output_style, .. } = msg {
|
|
assert_eq!(output_style, Some("auto".to_string()));
|
|
} else {
|
|
panic!("Expected System variant");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_system_init_without_output_style() {
|
|
let json = r#"{"type":"system","subtype":"init","session_id":"sess-1"}"#;
|
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
|
if let ClaudeMessage::System { output_style, .. } = msg {
|
|
assert!(output_style.is_none());
|
|
} else {
|
|
panic!("Expected System variant");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_system_init_with_plugin_errors() {
|
|
let json = r#"{"type":"system","subtype":"init","session_id":"sess-1","plugin_errors":["Plugin 'foo' requires 'bar' which is not installed","Plugin 'baz' failed to load"]}"#;
|
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
|
if let ClaudeMessage::System { plugin_errors, .. } = msg {
|
|
let errors = plugin_errors.expect("plugin_errors should be present");
|
|
let arr = errors.as_array().expect("plugin_errors should be an array");
|
|
assert_eq!(arr.len(), 2);
|
|
assert_eq!(arr[0].as_str(), Some("Plugin 'foo' requires 'bar' which is not installed"));
|
|
} else {
|
|
panic!("Expected System variant");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_system_init_without_plugin_errors() {
|
|
let json = r#"{"type":"system","subtype":"init","session_id":"sess-1"}"#;
|
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
|
if let ClaudeMessage::System { plugin_errors, .. } = msg {
|
|
assert!(plugin_errors.is_none());
|
|
} else {
|
|
panic!("Expected System variant");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_system_init_with_empty_plugin_errors() {
|
|
let json = r#"{"type":"system","subtype":"init","session_id":"sess-1","plugin_errors":[]}"#;
|
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
|
if let ClaudeMessage::System { plugin_errors, .. } = msg {
|
|
let errors = plugin_errors.expect("plugin_errors should be present");
|
|
let arr = errors.as_array().expect("plugin_errors should be an array");
|
|
assert!(arr.is_empty());
|
|
} else {
|
|
panic!("Expected System variant");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_result_message_with_fast_mode_state() {
|
|
let json = r#"{"type":"result","subtype":"success","fast_mode_state":"enabled"}"#;
|
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
|
if let ClaudeMessage::Result { fast_mode_state, .. } = msg {
|
|
assert_eq!(fast_mode_state, Some("enabled".to_string()));
|
|
} else {
|
|
panic!("Expected Result variant");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_result_message_with_total_cost_usd() {
|
|
let json = r#"{"type":"result","subtype":"success","total_cost_usd":0.05}"#;
|
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
|
if let ClaudeMessage::Result { total_cost_usd, .. } = msg {
|
|
assert!((total_cost_usd.unwrap() - 0.05).abs() < f64::EPSILON);
|
|
} else {
|
|
panic!("Expected Result variant");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_result_message_without_new_fields() {
|
|
let json = r#"{"type":"result","subtype":"success"}"#;
|
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
|
if let ClaudeMessage::Result {
|
|
fast_mode_state,
|
|
model_usage,
|
|
total_cost_usd,
|
|
..
|
|
} = msg
|
|
{
|
|
assert!(fast_mode_state.is_none());
|
|
assert!(model_usage.is_none());
|
|
assert!(total_cost_usd.is_none());
|
|
} else {
|
|
panic!("Expected Result variant");
|
|
}
|
|
}
|
|
}
|