tsk/agent/
claude_code.rs

1use super::{Agent, LogProcessor};
2use crate::context::file_system::FileSystemOperations;
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::path::Path;
7use std::process::Command;
8use std::sync::Arc;
9
10/// Claude Code AI agent implementation
11pub struct ClaudeCodeAgent;
12
13impl ClaudeCodeAgent {
14    pub fn new() -> Self {
15        Self
16    }
17}
18
19impl Default for ClaudeCodeAgent {
20    fn default() -> Self {
21        Self::new()
22    }
23}
24
25#[async_trait]
26impl Agent for ClaudeCodeAgent {
27    fn build_command(&self, instruction_path: &str) -> Vec<String> {
28        // Get just the filename from the instruction path
29        let filename = Path::new(instruction_path)
30            .file_name()
31            .and_then(|f| f.to_str())
32            .unwrap_or("instructions.md");
33
34        vec![
35            "sh".to_string(),
36            "-c".to_string(),
37            format!(
38                "cat /instructions/{} | claude -p --verbose --output-format stream-json --dangerously-skip-permissions",
39                filename
40            ),
41        ]
42    }
43
44    fn volumes(&self) -> Vec<(String, String, String)> {
45        // Get the home directory path for mounting ~/.claude and ~/.claude.json
46        let home_dir = std::env::var("HOME").unwrap_or_else(|_| "/home/agent".to_string());
47
48        vec![
49            // Claude config directory
50            (
51                format!("{home_dir}/.claude"),
52                "/home/agent/.claude".to_string(),
53                "".to_string(),
54            ),
55            // Claude config file
56            (
57                format!("{home_dir}/.claude.json"),
58                "/home/agent/.claude.json".to_string(),
59                "".to_string(),
60            ),
61        ]
62    }
63
64    fn environment(&self) -> Vec<(String, String)> {
65        vec![
66            ("HOME".to_string(), "/home/agent".to_string()),
67            ("USER".to_string(), "agent".to_string()),
68        ]
69    }
70
71    fn create_log_processor(
72        &self,
73        file_system: Arc<dyn FileSystemOperations>,
74    ) -> Box<dyn LogProcessor> {
75        Box::new(ClaudeCodeLogProcessor::new(file_system))
76    }
77
78    fn name(&self) -> &str {
79        "claude-code"
80    }
81
82    async fn validate(&self) -> Result<(), String> {
83        // Check if ~/.claude.json exists
84        let home_dir = std::env::var("HOME").map_err(|_| "HOME environment variable not set")?;
85        let claude_config = Path::new(&home_dir).join(".claude.json");
86
87        if !claude_config.exists() {
88            return Err(format!(
89                "Claude configuration not found at {}. Please run 'claude login' first.",
90                claude_config.display()
91            ));
92        }
93
94        Ok(())
95    }
96
97    async fn warmup(&self) -> Result<(), String> {
98        // Skip warmup in test environments
99        if cfg!(test) {
100            return Ok(());
101        }
102
103        println!("Running Claude Code warmup steps...");
104
105        // Step 1: Force Claude CLI to refresh token
106        let output = Command::new("claude")
107            .args(["-p", "--model", "sonnet", "say hi and nothing else"])
108            .output()
109            .map_err(|e| format!("Failed to run Claude CLI: {e}"))?;
110
111        if !output.status.success() {
112            return Err(format!(
113                "Claude CLI failed: {}",
114                String::from_utf8_lossy(&output.stderr)
115            ));
116        }
117
118        // Step 2: Export OAuth token on macOS only
119        if std::env::consts::OS == "macos" {
120            let user = std::env::var("USER").map_err(|_| "USER environment variable not set")?;
121
122            let output = Command::new("sh")
123                .arg("-c")
124                .arg(format!(
125                    "security find-generic-password -a {user} -w -s 'Claude Code-credentials' > ~/.claude/.credentials.json"
126                ))
127                .output()
128                .map_err(|e| format!("Failed to export OAuth token: {e}"))?;
129
130            if !output.status.success() {
131                // This might fail if the keychain item doesn't exist yet
132                eprintln!("Warning: Could not export OAuth token from keychain");
133            }
134        }
135
136        println!("Claude Code warmup completed successfully");
137        Ok(())
138    }
139}
140
141/// Represents a message from Claude Code's JSON output
142#[derive(Debug, Deserialize, Serialize)]
143struct ClaudeMessage {
144    #[serde(rename = "type")]
145    message_type: String,
146    message: Option<MessageContent>,
147    subtype: Option<String>,
148    cost_usd: Option<f64>,
149    is_error: Option<bool>,
150    duration_ms: Option<u64>,
151    duration_api_ms: Option<u64>,
152    num_turns: Option<u64>,
153    result: Option<String>,
154    total_cost: Option<f64>,
155    session_id: Option<String>,
156    timestamp: Option<String>,
157    #[serde(rename = "toolUseResult")]
158    tool_use_result: Option<ToolUseResult>,
159    summary: Option<String>,
160}
161
162/// Tool use result from user messages
163#[derive(Debug, Deserialize, Serialize)]
164struct ToolUseResult {
165    stdout: Option<String>,
166    stderr: Option<String>,
167    is_error: Option<bool>,
168    error: Option<String>,
169    filenames: Option<Vec<String>>,
170}
171
172/// Message content structure from Claude Code
173#[derive(Debug, Deserialize, Serialize)]
174struct MessageContent {
175    role: Option<String>,
176    content: Option<Value>,
177    id: Option<String>,
178    #[serde(rename = "type")]
179    content_type: Option<String>,
180    model: Option<String>,
181    stop_reason: Option<String>,
182    usage: Option<Usage>,
183}
184
185/// Usage information from Claude Code
186#[derive(Debug, Deserialize, Serialize)]
187struct Usage {
188    input_tokens: Option<u64>,
189    output_tokens: Option<u64>,
190    cache_creation_input_tokens: Option<u64>,
191    cache_read_input_tokens: Option<u64>,
192    service_tier: Option<String>,
193}
194
195/// Todo item structure from Claude Code's TodoWrite tool
196#[derive(Debug, Deserialize, Serialize, Clone)]
197struct TodoItem {
198    id: String,
199    content: String,
200    status: String,
201    priority: String,
202}
203
204/// Result of a task execution from Claude Code
205#[derive(Debug, Clone)]
206pub struct TaskResult {
207    pub success: bool,
208    pub message: String,
209    #[allow(dead_code)] // Available for future use
210    pub cost_usd: Option<f64>,
211    #[allow(dead_code)] // Available for future use
212    pub duration_ms: Option<u64>,
213}
214
215/// Claude Code specific log processor that parses and formats JSON output
216///
217/// This processor provides rich output including:
218/// - Tool usage information (Edit, Bash, Write, etc.)
219/// - Tool result summaries from user messages
220/// - TODO list updates with status indicators
221/// - Assistant reasoning and conversation
222/// - Summary messages
223/// - Cost calculations from token usage
224struct ClaudeCodeLogProcessor {
225    full_log: Vec<String>,
226    final_result: Option<TaskResult>,
227    file_system: Arc<dyn FileSystemOperations>,
228}
229
230impl ClaudeCodeLogProcessor {
231    /// Creates a new ClaudeCodeLogProcessor with the given file system
232    fn new(file_system: Arc<dyn FileSystemOperations>) -> Self {
233        Self {
234            full_log: Vec::new(),
235            final_result: None,
236            file_system,
237        }
238    }
239
240    /// Formats a Claude message based on its type
241    fn format_message(&mut self, msg: ClaudeMessage) -> Option<String> {
242        match msg.message_type.as_str() {
243            "assistant" => self.format_assistant_message(msg),
244            "user" => self.format_user_message(&msg),
245            "result" => self.format_result_message(msg),
246            "summary" => {
247                // Show summary messages
248                if let Some(summary_text) = msg.summary {
249                    Some(format!("📋 Summary: {summary_text}"))
250                } else {
251                    Some("📋 [summary]".to_string())
252                }
253            }
254            other_type => {
255                // For other message types, just show a brief indicator
256                Some(format!("📋 [{other_type}]"))
257            }
258        }
259    }
260
261    /// Formats a user message with tool result information
262    fn format_user_message(&self, msg: &ClaudeMessage) -> Option<String> {
263        if let Some(tool_result) = &msg.tool_use_result {
264            let mut output = String::new();
265
266            // Check for specific tool results
267            if let Some(filenames) = &tool_result.filenames {
268                let count = filenames.len();
269                output.push_str(&format!(
270                    "👤 Tool result: Found {count} file{}",
271                    if count == 1 { "" } else { "s" }
272                ));
273            } else if let Some(stdout) = &tool_result.stdout {
274                if stdout.contains("test result: ok") {
275                    output.push_str("👤 Tool result: Tests passed ✅");
276                } else if stdout.contains("test result: FAILED") {
277                    output.push_str("👤 Tool result: Tests failed ❌");
278                } else if stdout.trim().is_empty() {
279                    output.push_str("👤 Tool result: Command completed");
280                } else {
281                    // For other outputs, show a brief summary
282                    let first_line = stdout.lines().next().unwrap_or("").trim();
283                    if first_line.len() > 50 {
284                        output.push_str(&format!("👤 Tool result: {}...", &first_line[..50]));
285                    } else {
286                        output.push_str(&format!("👤 Tool result: {first_line}"));
287                    }
288                }
289            } else if let Some(error) = &tool_result.error {
290                output.push_str(&format!("👤 Tool error: {error}"));
291            } else if tool_result.is_error == Some(true) {
292                output.push_str("👤 Tool result: Error occurred");
293            } else {
294                output.push_str("👤 Tool result: Completed");
295            }
296
297            Some(output)
298        } else {
299            // Check if this is a regular user message with content
300            if let Some(message) = &msg.message {
301                if let Some(Value::String(text)) = &message.content {
302                    // Show brief summary of user message
303                    let first_line = text.lines().next().unwrap_or("").trim();
304                    if first_line.starts_with("# ") {
305                        Some(format!("👤 User: {}", first_line.trim_start_matches("# ")))
306                    } else if first_line.len() > 60 {
307                        Some(format!("👤 User: {}...", &first_line[..60]))
308                    } else if !first_line.is_empty() {
309                        Some(format!("👤 User: {first_line}"))
310                    } else {
311                        Some("👤 [user]".to_string())
312                    }
313                } else {
314                    Some("👤 [user]".to_string())
315                }
316            } else {
317                Some("👤 [user]".to_string())
318            }
319        }
320    }
321
322    /// Formats an assistant message, extracting text content, tool uses, and todo updates
323    fn format_assistant_message(&self, msg: ClaudeMessage) -> Option<String> {
324        if let Some(message) = msg.message {
325            if let Some(content) = message.content {
326                match content {
327                    Value::Array(contents) => {
328                        let mut output = String::new();
329
330                        for item in contents {
331                            // Check for tool use
332                            if let Some(tool_name) = item.get("name").and_then(|n| n.as_str()) {
333                                match tool_name {
334                                    "TodoWrite" => {
335                                        if let Some(input) = item.get("input") {
336                                            if let Some(todos) = input.get("todos") {
337                                                if let Ok(todo_items) =
338                                                    serde_json::from_value::<Vec<TodoItem>>(
339                                                        todos.clone(),
340                                                    )
341                                                {
342                                                    output.push_str(
343                                                        &self.format_todo_update(&todo_items),
344                                                    );
345                                                }
346                                            }
347                                        }
348                                    }
349                                    "Read" | "LS" | "NotebookRead" => {
350                                        // Skip file reading operations as requested
351                                    }
352                                    "Edit" | "MultiEdit" => {
353                                        if let Some(input) = item.get("input") {
354                                            if let Some(file_path) =
355                                                input.get("file_path").and_then(|f| f.as_str())
356                                            {
357                                                let file_name = file_path
358                                                    .rsplit('/')
359                                                    .next()
360                                                    .unwrap_or(file_path);
361                                                if tool_name == "MultiEdit" {
362                                                    if let Some(edits) = input
363                                                        .get("edits")
364                                                        .and_then(|e| e.as_array())
365                                                    {
366                                                        output.push_str(&format!(
367                                                            "🔧 Editing {file_name} ({} changes)\n",
368                                                            edits.len()
369                                                        ));
370                                                    } else {
371                                                        output.push_str(&format!(
372                                                            "🔧 Editing {file_name}\n"
373                                                        ));
374                                                    }
375                                                } else {
376                                                    output.push_str(&format!(
377                                                        "🔧 Editing {file_name}\n"
378                                                    ));
379                                                }
380                                            }
381                                        }
382                                    }
383                                    "Bash" => {
384                                        if let Some(input) = item.get("input") {
385                                            if let Some(cmd) =
386                                                input.get("command").and_then(|c| c.as_str())
387                                            {
388                                                let cmd_preview = if cmd.len() > 60 {
389                                                    format!("{}...", &cmd[..60])
390                                                } else {
391                                                    cmd.to_string()
392                                                };
393                                                output.push_str(&format!(
394                                                    "🖥️ Running: {cmd_preview}\n"
395                                                ));
396                                            }
397                                        }
398                                    }
399                                    "Write" => {
400                                        if let Some(input) = item.get("input") {
401                                            if let Some(file_path) =
402                                                input.get("file_path").and_then(|f| f.as_str())
403                                            {
404                                                let file_name = file_path
405                                                    .rsplit('/')
406                                                    .next()
407                                                    .unwrap_or(file_path);
408                                                output
409                                                    .push_str(&format!("📝 Writing {file_name}\n"));
410                                            }
411                                        }
412                                    }
413                                    "Grep" => {
414                                        if let Some(input) = item.get("input") {
415                                            if let Some(pattern) =
416                                                input.get("pattern").and_then(|p| p.as_str())
417                                            {
418                                                output.push_str(&format!(
419                                                    "🔍 Searching for: {pattern}\n"
420                                                ));
421                                            }
422                                        }
423                                    }
424                                    "WebSearch" => {
425                                        if let Some(input) = item.get("input") {
426                                            if let Some(query) =
427                                                input.get("query").and_then(|q| q.as_str())
428                                            {
429                                                output
430                                                    .push_str(&format!("🌐 Web search: {query}\n"));
431                                            }
432                                        }
433                                    }
434                                    _ => {
435                                        // Other tools
436                                        output.push_str(&format!("🔧 Using {tool_name}\n"));
437                                    }
438                                }
439                            }
440
441                            // Process regular text content
442                            if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
443                                if !text.trim().is_empty() {
444                                    if !output.is_empty() {
445                                        output.push('\n');
446                                    }
447                                    output.push_str(&format!("🤖 {text}"));
448                                }
449                            }
450                        }
451
452                        if !output.is_empty() {
453                            Some(output.trim_end().to_string())
454                        } else {
455                            None
456                        }
457                    }
458                    Value::String(text) => Some(format!("🤖 {text}")),
459                    _ => None,
460                }
461            } else {
462                None
463            }
464        } else {
465            None
466        }
467    }
468
469    /// Formats a todo list update with status indicators and summary
470    fn format_todo_update(&self, todos: &[TodoItem]) -> String {
471        let mut output = String::new();
472        output.push_str("📝 TODO Update:\n");
473        output.push_str(&"─".repeat(60));
474        output.push('\n');
475
476        // Display todos in their original order with status emoji
477        for todo in todos {
478            let status_emoji = match todo.status.as_str() {
479                "in_progress" => "🔄",
480                "pending" => "⏳",
481                "completed" => "✅",
482                _ => "⏳", // Default to pending for unknown statuses
483            };
484
485            output.push_str(&format!(
486                "{} {} [{}] {}\n",
487                status_emoji,
488                self.get_priority_emoji(&todo.priority),
489                todo.id,
490                todo.content
491            ));
492        }
493
494        output.push_str(&"─".repeat(60));
495        output.push('\n');
496
497        // Add summary
498        let total = todos.len();
499        let completed = todos.iter().filter(|t| t.status == "completed").count();
500        let in_progress = todos.iter().filter(|t| t.status == "in_progress").count();
501        let pending = todos.iter().filter(|t| t.status == "pending").count();
502
503        output.push_str(&format!(
504            "Summary: {total} total | {completed} completed | {in_progress} in progress | {pending} pending\n"
505        ));
506        output.push_str(&"─".repeat(60));
507
508        output
509    }
510
511    /// Returns an emoji for the given priority level
512    fn get_priority_emoji(&self, priority: &str) -> &'static str {
513        match priority {
514            "high" => "🔴",
515            "medium" => "🟡",
516            "low" => "🟢",
517            _ => "⚪",
518        }
519    }
520
521    /// Formats duration in milliseconds to a human-readable string.
522    ///
523    /// # Examples
524    /// - 1500 ms -> "1 second"
525    /// - 130000 ms -> "2 minutes, 10 seconds"
526    /// - 3661000 ms -> "1 hour, 1 minute, 1 second"
527    fn format_duration(&self, duration_ms: u64) -> String {
528        let total_seconds = duration_ms / 1000;
529        let hours = total_seconds / 3600;
530        let minutes = (total_seconds % 3600) / 60;
531        let seconds = total_seconds % 60;
532
533        let mut parts = Vec::new();
534
535        if hours > 0 {
536            parts.push(format!(
537                "{} hour{}",
538                hours,
539                if hours == 1 { "" } else { "s" }
540            ));
541        }
542
543        if minutes > 0 {
544            parts.push(format!(
545                "{} minute{}",
546                minutes,
547                if minutes == 1 { "" } else { "s" }
548            ));
549        }
550
551        if seconds > 0 || parts.is_empty() {
552            parts.push(format!(
553                "{} second{}",
554                seconds,
555                if seconds == 1 { "" } else { "s" }
556            ));
557        }
558
559        parts.join(", ")
560    }
561
562    /// Formats a result message and stores the task result
563    fn format_result_message(&mut self, msg: ClaudeMessage) -> Option<String> {
564        // Parse and store the result status
565        if let Some(subtype) = &msg.subtype {
566            let success = subtype == "success";
567            let message = msg.result.clone().unwrap_or_else(|| {
568                if success {
569                    "Task completed successfully".to_string()
570                } else {
571                    "Task failed".to_string()
572                }
573            });
574
575            // Calculate cost from usage if not directly provided
576            let cost_usd = msg.cost_usd.or_else(|| {
577                if let Some(message_content) = &msg.message {
578                    if let Some(usage) = &message_content.usage {
579                        self.calculate_cost_from_usage(usage)
580                    } else {
581                        None
582                    }
583                } else {
584                    None
585                }
586            });
587
588            self.final_result = Some(TaskResult {
589                success,
590                message: message.clone(),
591                cost_usd,
592                duration_ms: msg.duration_ms,
593            });
594
595            // Format a nice summary
596            let mut output = String::new();
597            output.push('\n');
598            output.push_str(&"─".repeat(60));
599            output.push('\n');
600
601            let status_emoji = if success { "✅" } else { "❌" };
602            output.push_str(&format!("{status_emoji} Task Result: {subtype}\n"));
603
604            if let Some(cost) = cost_usd {
605                output.push_str(&format!("💰 Cost: ${cost:.2}\n"));
606            }
607
608            if let Some(duration) = msg.duration_ms {
609                output.push_str(&format!(
610                    "⏱️ Duration: {}\n",
611                    self.format_duration(duration)
612                ));
613            }
614
615            if let Some(turns) = msg.num_turns {
616                output.push_str(&format!("🔄 Turns: {turns}\n"));
617            }
618
619            output.push_str(&"─".repeat(60));
620
621            Some(output)
622        } else {
623            None
624        }
625    }
626
627    /// Calculates approximate cost from token usage
628    fn calculate_cost_from_usage(&self, usage: &Usage) -> Option<f64> {
629        // Approximate pricing for Claude Opus
630        // These are rough estimates and should be updated based on actual pricing
631        const INPUT_COST_PER_1K: f64 = 0.015; // $15 per million tokens
632        const OUTPUT_COST_PER_1K: f64 = 0.075; // $75 per million tokens
633        const CACHE_WRITE_COST_PER_1K: f64 = 0.01875; // 25% more than input
634        const CACHE_READ_COST_PER_1K: f64 = 0.0015; // 90% cheaper than input
635
636        let input_tokens = usage.input_tokens.unwrap_or(0) as f64;
637        let output_tokens = usage.output_tokens.unwrap_or(0) as f64;
638        let cache_write_tokens = usage.cache_creation_input_tokens.unwrap_or(0) as f64;
639        let cache_read_tokens = usage.cache_read_input_tokens.unwrap_or(0) as f64;
640
641        let total_cost = (input_tokens * INPUT_COST_PER_1K / 1000.0)
642            + (output_tokens * OUTPUT_COST_PER_1K / 1000.0)
643            + (cache_write_tokens * CACHE_WRITE_COST_PER_1K / 1000.0)
644            + (cache_read_tokens * CACHE_READ_COST_PER_1K / 1000.0);
645
646        if total_cost > 0.0 {
647            Some(total_cost)
648        } else {
649            None
650        }
651    }
652}
653
654#[async_trait]
655impl LogProcessor for ClaudeCodeLogProcessor {
656    fn process_line(&mut self, line: &str) -> Option<String> {
657        // Store the raw line for the full log
658        self.full_log.push(line.to_string());
659
660        // Skip empty lines
661        if line.trim().is_empty() {
662            return None;
663        }
664
665        // Try to parse as JSON
666        match serde_json::from_str::<ClaudeMessage>(line) {
667            Ok(msg) => self.format_message(msg),
668            Err(_) => {
669                // If it's not JSON, return a parsing error indicator
670                Some("‼️ parsing error".to_string())
671            }
672        }
673    }
674
675    fn get_full_log(&self) -> String {
676        self.full_log.join("\n")
677    }
678
679    async fn save_full_log(&self, path: &Path) -> Result<(), String> {
680        let content = self.full_log.join("\n");
681        self.file_system
682            .write_file(path, &content)
683            .await
684            .map_err(|e| format!("Failed to save log file: {e}"))
685    }
686
687    fn get_final_result(&self) -> Option<&TaskResult> {
688        self.final_result.as_ref()
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use crate::context::file_system::tests::MockFileSystem;
696
697    #[test]
698    fn test_process_assistant_message() {
699        let fs = Arc::new(MockFileSystem::new());
700        let mut processor = ClaudeCodeLogProcessor::new(fs);
701        let json = r#"{
702            "type": "assistant",
703            "message": {
704                "content": [{"type": "text", "text": "Hello, world!"}]
705            }
706        }"#;
707
708        let result = processor.process_line(json);
709        assert_eq!(result, Some("🤖 Hello, world!".to_string()));
710    }
711
712    #[test]
713    fn test_process_result_message() {
714        let fs = Arc::new(MockFileSystem::new());
715        let mut processor = ClaudeCodeLogProcessor::new(fs);
716        let json = r#"{
717            "type": "result",
718            "subtype": "success",
719            "cost_usd": 0.123,
720            "result": "Task completed successfully with all tests passing"
721        }"#;
722
723        let result = processor.process_line(json);
724        assert!(result.is_some());
725        let formatted = result.unwrap();
726        assert!(formatted.contains("✅ Task Result: success"));
727        assert!(formatted.contains("💰 Cost: $0.12"));
728
729        // Check that the result was parsed correctly
730        let final_result = processor.get_final_result();
731        assert!(final_result.is_some());
732        let task_result = final_result.unwrap();
733        assert_eq!(task_result.success, true);
734        assert_eq!(
735            task_result.message,
736            "Task completed successfully with all tests passing"
737        );
738        assert_eq!(task_result.cost_usd, Some(0.123));
739    }
740
741    #[test]
742    fn test_process_result_message_failure() {
743        let fs = Arc::new(MockFileSystem::new());
744        let mut processor = ClaudeCodeLogProcessor::new(fs);
745        let json = r#"{
746            "type": "result",
747            "subtype": "error",
748            "is_error": true,
749            "result": "Task failed due to compilation errors",
750            "duration_ms": 5000
751        }"#;
752
753        let result = processor.process_line(json);
754        assert!(result.is_some());
755        let formatted = result.unwrap();
756        assert!(formatted.contains("❌ Task Result: error"));
757        assert!(formatted.contains("⏱️ Duration: 5 seconds"));
758
759        // Check that the failure was parsed correctly
760        let final_result = processor.get_final_result();
761        assert!(final_result.is_some());
762        let task_result = final_result.unwrap();
763        assert_eq!(task_result.success, false);
764        assert_eq!(task_result.message, "Task failed due to compilation errors");
765        assert_eq!(task_result.duration_ms, Some(5000));
766    }
767
768    #[test]
769    fn test_process_non_json() {
770        let fs = Arc::new(MockFileSystem::new());
771        let mut processor = ClaudeCodeLogProcessor::new(fs);
772        let line = "This is not JSON";
773
774        let result = processor.process_line(line);
775        assert_eq!(result, Some("‼️ parsing error".to_string()));
776    }
777
778    #[test]
779    fn test_process_other_message_types() {
780        let fs = Arc::new(MockFileSystem::new());
781        let mut processor = ClaudeCodeLogProcessor::new(fs);
782
783        // Test a message with an unknown type - tool_use
784        let json = r#"{"type": "tool_use"}"#;
785        let result = processor.process_line(json);
786        assert_eq!(result, Some("📋 [tool_use]".to_string()));
787
788        // Test another unknown type - system
789        let json = r#"{"type": "system"}"#;
790        let result = processor.process_line(json);
791        assert_eq!(result, Some("📋 [system]".to_string()));
792
793        // Test with more complete message structure
794        let json = r#"{"type": "thinking", "message": {"content": "Processing..."}}"#;
795        let result = processor.process_line(json);
796        assert_eq!(result, Some("📋 [thinking]".to_string()));
797    }
798
799    #[test]
800    fn test_process_todo_update() {
801        let fs = Arc::new(MockFileSystem::new());
802        let mut processor = ClaudeCodeLogProcessor::new(fs);
803        let json = r#"{
804            "type": "assistant",
805            "message": {
806                "id": "msg_01715dTbzrJ49yvb5Mp68sQa",
807                "type": "message",
808                "role": "assistant",
809                "model": "claude-opus-4-20250514",
810                "content": [{
811                    "type": "tool_use",
812                    "id": "toolu_013pfL2AAyzkXVLeuGBrD2Z1",
813                    "name": "TodoWrite",
814                    "input": {
815                        "todos": [
816                            {"id": "1", "content": "Analyze existing MockDockerClient implementations", "status": "pending", "priority": "high"},
817                            {"id": "2", "content": "Create test_utils module structure", "status": "pending", "priority": "high"},
818                            {"id": "3", "content": "Implement NoOpDockerClient", "status": "completed", "priority": "high"},
819                            {"id": "4", "content": "Run tests and fix any issues", "status": "in_progress", "priority": "medium"}
820                        ]
821                    }
822                }]
823            }
824        }"#;
825
826        let result = processor.process_line(json);
827        assert!(result.is_some());
828        let formatted = result.unwrap();
829
830        // Check that the TODO update header is present
831        assert!(formatted.contains("📝 TODO Update:"));
832
833        // Check that todos have status emojis in the original order
834        let lines: Vec<&str> = formatted.lines().collect();
835        let todo_lines: Vec<&str> = lines
836            .iter()
837            .filter(|line| {
838                line.contains("[1]")
839                    || line.contains("[2]")
840                    || line.contains("[3]")
841                    || line.contains("[4]")
842            })
843            .cloned()
844            .collect();
845
846        // Verify the order is preserved as in the input
847        assert_eq!(todo_lines.len(), 4);
848        assert!(todo_lines[0].starts_with("⏳ 🔴 [1]")); // pending, high priority
849        assert!(todo_lines[1].starts_with("⏳ 🔴 [2]")); // pending, high priority
850        assert!(todo_lines[2].starts_with("✅ 🔴 [3]")); // completed, high priority
851        assert!(todo_lines[3].starts_with("🔄 🟡 [4]")); // in_progress, medium priority
852
853        // Check that specific todo items are present
854        assert!(formatted.contains("Analyze existing MockDockerClient implementations"));
855        assert!(formatted.contains("Create test_utils module structure"));
856        assert!(formatted.contains("Implement NoOpDockerClient"));
857        assert!(formatted.contains("Run tests and fix any issues"));
858
859        // Check priority emojis
860        assert!(formatted.contains("🔴")); // high priority
861        assert!(formatted.contains("🟡")); // medium priority
862
863        // Check summary
864        assert!(formatted.contains("Summary: 4 total | 1 completed | 1 in progress | 2 pending"));
865    }
866
867    #[test]
868    fn test_process_todo_update_all_completed() {
869        let fs = Arc::new(MockFileSystem::new());
870        let mut processor = ClaudeCodeLogProcessor::new(fs);
871        let json = r#"{
872            "type": "assistant",
873            "message": {
874                "content": [{
875                    "type": "tool_use",
876                    "name": "TodoWrite",
877                    "input": {
878                        "todos": [
879                            {"id": "1", "content": "Task 1", "status": "completed", "priority": "high"},
880                            {"id": "2", "content": "Task 2", "status": "completed", "priority": "low"}
881                        ]
882                    }
883                }]
884            }
885        }"#;
886
887        let result = processor.process_line(json);
888        assert!(result.is_some());
889        let formatted = result.unwrap();
890
891        // Check that both todos have completed status emoji
892        let lines: Vec<&str> = formatted.lines().collect();
893        let todo_lines: Vec<&str> = lines
894            .iter()
895            .filter(|line| line.contains("[1]") || line.contains("[2]"))
896            .cloned()
897            .collect();
898
899        assert_eq!(todo_lines.len(), 2);
900        assert!(todo_lines[0].starts_with("✅ 🔴 [1]")); // completed, high priority
901        assert!(todo_lines[1].starts_with("✅ 🟢 [2]")); // completed, low priority
902
903        // Check summary
904        assert!(formatted.contains("Summary: 2 total | 2 completed | 0 in progress | 0 pending"));
905    }
906
907    #[test]
908    fn test_todo_priority_emojis() {
909        let fs = Arc::new(MockFileSystem::new());
910        let processor = ClaudeCodeLogProcessor::new(fs);
911        assert_eq!(processor.get_priority_emoji("high"), "🔴");
912        assert_eq!(processor.get_priority_emoji("medium"), "🟡");
913        assert_eq!(processor.get_priority_emoji("low"), "🟢");
914        assert_eq!(processor.get_priority_emoji("unknown"), "⚪");
915    }
916
917    #[test]
918    fn test_process_result_message_with_long_duration() {
919        let fs = Arc::new(MockFileSystem::new());
920        let mut processor = ClaudeCodeLogProcessor::new(fs);
921        let json = r#"{
922            "type": "result",
923            "subtype": "success",
924            "duration_ms": 130000,
925            "result": "Task completed after processing"
926        }"#;
927
928        let result = processor.process_line(json);
929        assert!(result.is_some());
930        let formatted = result.unwrap();
931        assert!(formatted.contains("✅ Task Result: success"));
932        assert!(formatted.contains("⏱️ Duration: 2 minutes, 10 seconds"));
933    }
934
935    #[test]
936    fn test_format_duration() {
937        let fs = Arc::new(MockFileSystem::new());
938        let processor = ClaudeCodeLogProcessor::new(fs);
939
940        // Test seconds only
941        assert_eq!(processor.format_duration(0), "0 seconds");
942        assert_eq!(processor.format_duration(1000), "1 second");
943        assert_eq!(processor.format_duration(45000), "45 seconds");
944
945        // Test minutes and seconds
946        assert_eq!(processor.format_duration(60000), "1 minute");
947        assert_eq!(processor.format_duration(61000), "1 minute, 1 second");
948        assert_eq!(processor.format_duration(130000), "2 minutes, 10 seconds");
949        assert_eq!(processor.format_duration(180000), "3 minutes");
950
951        // Test hours, minutes, and seconds
952        assert_eq!(processor.format_duration(3600000), "1 hour");
953        assert_eq!(
954            processor.format_duration(3661000),
955            "1 hour, 1 minute, 1 second"
956        );
957        assert_eq!(
958            processor.format_duration(7321000),
959            "2 hours, 2 minutes, 1 second"
960        );
961        assert_eq!(processor.format_duration(10800000), "3 hours");
962
963        // Test edge cases
964        assert_eq!(processor.format_duration(3660000), "1 hour, 1 minute");
965        assert_eq!(processor.format_duration(7200000), "2 hours");
966        assert_eq!(
967            processor.format_duration(86399000),
968            "23 hours, 59 minutes, 59 seconds"
969        );
970    }
971
972    #[test]
973    fn test_todo_order_preservation() {
974        let fs = Arc::new(MockFileSystem::new());
975        let mut processor = ClaudeCodeLogProcessor::new(fs);
976        let json = r#"{
977            "type": "assistant",
978            "message": {
979                "content": [{
980                    "type": "tool_use",
981                    "name": "TodoWrite",
982                    "input": {
983                        "todos": [
984                            {"id": "1", "content": "First task", "status": "completed", "priority": "low"},
985                            {"id": "2", "content": "Second task", "status": "in_progress", "priority": "high"},
986                            {"id": "3", "content": "Third task", "status": "pending", "priority": "medium"},
987                            {"id": "4", "content": "Fourth task", "status": "completed", "priority": "high"},
988                            {"id": "5", "content": "Fifth task", "status": "pending", "priority": "low"}
989                        ]
990                    }
991                }]
992            }
993        }"#;
994
995        let result = processor.process_line(json);
996        assert!(result.is_some());
997        let formatted = result.unwrap();
998
999        // Extract the todo lines in order
1000        let lines: Vec<&str> = formatted.lines().collect();
1001        let todo_lines: Vec<&str> = lines
1002            .iter()
1003            .filter(|line| {
1004                line.contains("task")
1005                    && (line.contains("[1]")
1006                        || line.contains("[2]")
1007                        || line.contains("[3]")
1008                        || line.contains("[4]")
1009                        || line.contains("[5]"))
1010            })
1011            .cloned()
1012            .collect();
1013
1014        // Verify order is exactly as provided in input
1015        assert_eq!(todo_lines.len(), 5);
1016        assert!(todo_lines[0].contains("First task") && todo_lines[0].starts_with("✅"));
1017        assert!(todo_lines[1].contains("Second task") && todo_lines[1].starts_with("🔄"));
1018        assert!(todo_lines[2].contains("Third task") && todo_lines[2].starts_with("⏳"));
1019        assert!(todo_lines[3].contains("Fourth task") && todo_lines[3].starts_with("✅"));
1020        assert!(todo_lines[4].contains("Fifth task") && todo_lines[4].starts_with("⏳"));
1021    }
1022
1023    #[test]
1024    fn test_user_message() {
1025        let fs = Arc::new(MockFileSystem::new());
1026        let mut processor = ClaudeCodeLogProcessor::new(fs);
1027        let json = r#"{
1028            "type": "user",
1029            "message": {
1030                "content": "User input message"
1031            }
1032        }"#;
1033
1034        let result = processor.process_line(json);
1035        assert_eq!(result, Some("👤 User: User input message".to_string()));
1036    }
1037
1038    #[test]
1039    fn test_user_message_with_tool_result() {
1040        let fs = Arc::new(MockFileSystem::new());
1041        let mut processor = ClaudeCodeLogProcessor::new(fs);
1042        let json = r#"{
1043            "type": "user",
1044            "toolUseResult": {
1045                "stdout": "test result: ok. 5 passed; 0 failed",
1046                "stderr": "",
1047                "is_error": false
1048            }
1049        }"#;
1050
1051        let result = processor.process_line(json);
1052        assert_eq!(result, Some("👤 Tool result: Tests passed ✅".to_string()));
1053    }
1054
1055    #[test]
1056    fn test_user_message_with_file_search_result() {
1057        let fs = Arc::new(MockFileSystem::new());
1058        let mut processor = ClaudeCodeLogProcessor::new(fs);
1059        let json = r#"{
1060            "type": "user",
1061            "toolUseResult": {
1062                "filenames": ["file1.rs", "file2.rs", "file3.rs"]
1063            }
1064        }"#;
1065
1066        let result = processor.process_line(json);
1067        assert_eq!(result, Some("👤 Tool result: Found 3 files".to_string()));
1068    }
1069
1070    #[test]
1071    fn test_assistant_message_with_tool_use() {
1072        let fs = Arc::new(MockFileSystem::new());
1073        let mut processor = ClaudeCodeLogProcessor::new(fs);
1074        let json = r#"{
1075            "type": "assistant",
1076            "message": {
1077                "content": [{
1078                    "type": "tool_use",
1079                    "name": "Bash",
1080                    "input": {
1081                        "command": "cargo test --all"
1082                    }
1083                }]
1084            }
1085        }"#;
1086
1087        let result = processor.process_line(json);
1088        assert!(result.is_some());
1089        assert!(result.unwrap().contains("🖥️ Running: cargo test --all"));
1090    }
1091
1092    #[test]
1093    fn test_assistant_message_with_edit_tool() {
1094        let fs = Arc::new(MockFileSystem::new());
1095        let mut processor = ClaudeCodeLogProcessor::new(fs);
1096        let json = r#"{
1097            "type": "assistant",
1098            "message": {
1099                "content": [{
1100                    "type": "tool_use",
1101                    "name": "Edit",
1102                    "input": {
1103                        "file_path": "/workspace/src/main.rs"
1104                    }
1105                }]
1106            }
1107        }"#;
1108
1109        let result = processor.process_line(json);
1110        assert!(result.is_some());
1111        assert!(result.unwrap().contains("🔧 Editing main.rs"));
1112    }
1113
1114    #[test]
1115    fn test_cost_calculation_from_usage() {
1116        let fs = Arc::new(MockFileSystem::new());
1117        let processor = ClaudeCodeLogProcessor::new(fs);
1118
1119        let usage = Usage {
1120            input_tokens: Some(1000),
1121            output_tokens: Some(500),
1122            cache_creation_input_tokens: Some(0),
1123            cache_read_input_tokens: Some(0),
1124            service_tier: Some("standard".to_string()),
1125        };
1126
1127        let cost = processor.calculate_cost_from_usage(&usage);
1128        assert!(cost.is_some());
1129        // 1000 * 0.015 / 1000 + 500 * 0.075 / 1000 = 0.015 + 0.0375 = 0.0525
1130        assert!((cost.unwrap() - 0.0525).abs() < 0.0001);
1131    }
1132
1133    #[test]
1134    fn test_summary_message() {
1135        let fs = Arc::new(MockFileSystem::new());
1136        let mut processor = ClaudeCodeLogProcessor::new(fs);
1137        let json = r#"{
1138            "type": "summary",
1139            "summary": "Test Summary: Running unit tests"
1140        }"#;
1141
1142        let result = processor.process_line(json);
1143        assert_eq!(
1144            result,
1145            Some("📋 Summary: Test Summary: Running unit tests".to_string())
1146        );
1147    }
1148
1149    #[test]
1150    fn test_empty_line_processing() {
1151        let fs = Arc::new(MockFileSystem::new());
1152        let mut processor = ClaudeCodeLogProcessor::new(fs);
1153
1154        let result = processor.process_line("");
1155        assert!(result.is_none());
1156
1157        let result = processor.process_line("   ");
1158        assert!(result.is_none());
1159    }
1160
1161    #[tokio::test]
1162    async fn test_save_full_log() {
1163        let fs = Arc::new(MockFileSystem::new());
1164        let mut processor = ClaudeCodeLogProcessor::new(fs.clone());
1165
1166        // Process some lines
1167        processor.process_line(r#"{"type": "user"}"#);
1168        processor.process_line("Regular text line");
1169        processor.process_line(r#"{"type": "result", "subtype": "success"}"#);
1170
1171        // Save the log
1172        let path = std::path::Path::new("/test/log.txt");
1173        let result = processor.save_full_log(path).await;
1174        assert!(result.is_ok());
1175
1176        // Verify the log was saved
1177        let content = fs.read_file(path).await.unwrap();
1178        assert!(content.contains(r#"{"type": "user"}"#));
1179        assert!(content.contains("Regular text line"));
1180        assert!(content.contains(r#"{"type": "result", "subtype": "success"}"#));
1181    }
1182
1183    #[test]
1184    fn test_get_full_log() {
1185        let fs = Arc::new(MockFileSystem::new());
1186        let mut processor = ClaudeCodeLogProcessor::new(fs);
1187
1188        // Process some lines
1189        processor.process_line("Line 1");
1190        processor.process_line("Line 2");
1191        processor.process_line("Line 3");
1192
1193        let full_log = processor.get_full_log();
1194        assert_eq!(full_log, "Line 1\nLine 2\nLine 3");
1195    }
1196
1197    #[test]
1198    fn test_claude_code_agent_properties() {
1199        let agent = ClaudeCodeAgent::new();
1200
1201        // Test name
1202        assert_eq!(agent.name(), "claude-code");
1203
1204        // Test volumes
1205        let volumes = agent.volumes();
1206        assert_eq!(volumes.len(), 2);
1207
1208        // Should mount .claude directory and .claude.json file
1209        let volume_paths: Vec<&str> = volumes
1210            .iter()
1211            .map(|(_, container_path, _)| container_path.as_str())
1212            .collect();
1213        assert!(volume_paths.contains(&"/home/agent/.claude"));
1214        assert!(volume_paths.contains(&"/home/agent/.claude.json"));
1215
1216        // Test environment variables
1217        let env = agent.environment();
1218        assert_eq!(env.len(), 2);
1219
1220        let env_map: std::collections::HashMap<_, _> = env.into_iter().collect();
1221        assert_eq!(env_map.get("HOME"), Some(&"/home/agent".to_string()));
1222        assert_eq!(env_map.get("USER"), Some(&"agent".to_string()));
1223    }
1224
1225    #[test]
1226    fn test_claude_code_agent_build_command() {
1227        let agent = ClaudeCodeAgent::new();
1228
1229        // Test with full path
1230        let command = agent.build_command("/tmp/instructions.md");
1231        assert_eq!(command.len(), 3);
1232        assert_eq!(command[0], "sh");
1233        assert_eq!(command[1], "-c");
1234        assert!(command[2].contains("cat /instructions/instructions.md"));
1235        assert!(command[2].contains("claude -p --verbose --output-format stream-json"));
1236
1237        // Test with complex path
1238        let command = agent.build_command("/path/to/task/instructions.txt");
1239        assert!(command[2].contains("cat /instructions/instructions.txt"));
1240    }
1241
1242    #[tokio::test]
1243    async fn test_claude_code_agent_validate_without_config() {
1244        // Create a temporary HOME directory without .claude.json
1245        let temp_dir = tempfile::tempdir().unwrap();
1246        unsafe {
1247            std::env::set_var("HOME", temp_dir.path());
1248        }
1249
1250        let agent = ClaudeCodeAgent::new();
1251        let result = agent.validate().await;
1252
1253        assert!(result.is_err());
1254        assert!(
1255            result
1256                .unwrap_err()
1257                .contains("Claude configuration not found")
1258        );
1259    }
1260
1261    #[test]
1262    fn test_claude_code_agent_create_log_processor() {
1263        let fs = Arc::new(MockFileSystem::new());
1264        let agent = ClaudeCodeAgent::new();
1265
1266        let log_processor = agent.create_log_processor(fs);
1267
1268        // Just verify we can create a log processor
1269        // The actual log processor functionality is tested elsewhere
1270        let _ = log_processor.get_full_log();
1271    }
1272}