diff --git a/backend/src/executor.rs b/backend/src/executor.rs index 22cc2e29..b6b117b8 100644 --- a/backend/src/executor.rs +++ b/backend/src/executor.rs @@ -220,7 +220,11 @@ pub trait Executor: Send + Sync { ) -> Result; /// Normalize executor logs into a standard format - fn normalize_logs(&self, _logs: &str) -> Result { + fn normalize_logs( + &self, + _logs: &str, + _worktree_path: &str, + ) -> Result { // Default implementation returns empty conversation Ok(NormalizedConversation { entries: vec![], diff --git a/backend/src/executor_tests.rs b/backend/src/executor_tests.rs index 69ac1808..c95feac5 100644 --- a/backend/src/executor_tests.rs +++ b/backend/src/executor_tests.rs @@ -12,7 +12,9 @@ mod tests { {"type":"messages","messages":[[0,{"role":"user","content":[{"type":"text","text":"Task title: Create and start should open task\nTask description: When I press 'create & start' on task creation dialog it should then open the task in the sidebar"}],"meta":{"sentAt":1751544747623}}]],"toolResults":[]} {"type":"messages","messages":[[1,{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants to implement a feature where pressing \"create & start\" on the task creation dialog should open the task in the sidebar."},{"type":"text","text":"I'll help you implement the \"create & start\" functionality. Let me explore the codebase to understand the current task creation and sidebar structure."},{"type":"tool_use","id":"toolu_01FQqskzGAhZaZu8H6qSs5pV","name":"todo_write","input":{"todos":[{"id":"1","content":"Explore task creation dialog component","status":"todo","priority":"high"}]}}],"state":{"type":"complete","stopReason":"tool_use"}}]],"toolResults":[]}"#; - let result = amp_executor.normalize_logs(amp_logs).unwrap(); + let result = amp_executor + .normalize_logs(amp_logs, "/tmp/test-worktree") + .unwrap(); assert_eq!(result.executor_type, "amp"); assert_eq!( @@ -68,7 +70,9 @@ mod tests { {"type":"assistant","message":{"id":"msg_014xUHgkAhs6cRx5WVT3s7if","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"I'll help you list your projects using vibe-kanban. Let me first explore the codebase to understand how vibe-kanban works and find your projects."}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":13497,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"499dcce4-04aa-4a3e-9e0c-ea0228fa87c9"} {"type":"assistant","message":{"id":"msg_014xUHgkAhs6cRx5WVT3s7if","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"tool_use","id":"toolu_01Br3TvXdmW6RPGpB5NihTHh","name":"Task","input":{"description":"Find vibe-kanban projects","prompt":"I need to find and list projects using vibe-kanban."}}],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":13497,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}},"parent_tool_use_id":null,"session_id":"499dcce4-04aa-4a3e-9e0c-ea0228fa87c9"}"#; - let result = claude_executor.normalize_logs(claude_logs).unwrap(); + let result = claude_executor + .normalize_logs(claude_logs, "/tmp/test-worktree") + .unwrap(); assert_eq!(result.executor_type, "claude"); assert_eq!( diff --git a/backend/src/executors/amp.rs b/backend/src/executors/amp.rs index 7a07a649..7a602c3d 100644 --- a/backend/src/executors/amp.rs +++ b/backend/src/executors/amp.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use async_trait::async_trait; use command_group::{AsyncCommandGroup, AsyncGroupChild}; use uuid::Uuid; @@ -37,18 +39,15 @@ impl Executor for AmpExecutor { use tokio::{io::AsyncWriteExt, process::Command}; - let prompt = format!( - r#"project_id: {} - - Task title: {} - Task description: {} - "#, - task.project_id, - task.title, - task.description - .as_deref() - .unwrap_or("No description provided") - ); + let prompt = if let Some(task_description) = task.description { + format!( + r#"Task title: {} +Task description: {}"#, + task.title, task_description + ) + } else { + task.title.clone() + }; // Use shell command for cross-platform compatibility let (shell_cmd, shell_arg) = get_shell_command(); @@ -82,7 +81,11 @@ impl Executor for AmpExecutor { Ok(child) } - fn normalize_logs(&self, logs: &str) -> Result { + fn normalize_logs( + &self, + logs: &str, + _worktree_path: &str, + ) -> Result { use serde_json::Value; let mut entries = Vec::new(); @@ -95,8 +98,19 @@ impl Executor for AmpExecutor { } // Try to parse as JSON - let json: Value = serde_json::from_str(trimmed) - .map_err(|e| format!("Failed to parse JSON: {}", e))?; + let json: Value = match serde_json::from_str(trimmed) { + Ok(json) => json, + Err(_) => { + // If line isn't valid JSON, add it as raw text + entries.push(NormalizedEntry { + timestamp: None, + entry_type: NormalizedEntryType::SystemMessage, + content: format!("Raw output: {}", trimmed), + metadata: None, + }); + continue; + } + }; // Extract session ID (threadID in AMP) if session_id.is_none() { @@ -106,99 +120,104 @@ impl Executor for AmpExecutor { } // Process different message types - if let Some(msg_type) = json.get("type").and_then(|t| t.as_str()) { - if msg_type == "messages" { - if let Some(messages) = json.get("messages").and_then(|m| m.as_array()) { - for message_entry in messages { - if let Some(message_data) = - message_entry.as_array().and_then(|arr| arr.get(1)) - { - if let Some(role) = - message_data.get("role").and_then(|r| r.as_str()) + let processed = if let Some(msg_type) = json.get("type").and_then(|t| t.as_str()) { + match msg_type { + "messages" => { + if let Some(messages) = json.get("messages").and_then(|m| m.as_array()) { + for message_entry in messages { + if let Some(message_data) = + message_entry.as_array().and_then(|arr| arr.get(1)) { - if let Some(content) = - message_data.get("content").and_then(|c| c.as_array()) + if let Some(role) = + message_data.get("role").and_then(|r| r.as_str()) { - for content_item in content { - if let Some(content_type) = - content_item.get("type").and_then(|t| t.as_str()) - { - match content_type { - "text" => { - if let Some(text) = content_item - .get("text") - .and_then(|t| t.as_str()) - { - let entry_type = match role { - "user" => NormalizedEntryType::UserMessage, - "assistant" => NormalizedEntryType::AssistantMessage, - _ => continue, - }; - entries.push(NormalizedEntry { - timestamp: message_data - .get("meta") - .and_then(|m| m.get("sentAt")) - .and_then(|s| s.as_u64()) - .map(|ts| ts.to_string()), - entry_type, - content: text.to_string(), - metadata: Some( - content_item.clone(), - ), - }); + if let Some(content) = + message_data.get("content").and_then(|c| c.as_array()) + { + for content_item in content { + if let Some(content_type) = content_item + .get("type") + .and_then(|t| t.as_str()) + { + match content_type { + "text" => { + if let Some(text) = content_item + .get("text") + .and_then(|t| t.as_str()) + { + let entry_type = match role { + "user" => NormalizedEntryType::UserMessage, + "assistant" => NormalizedEntryType::AssistantMessage, + _ => continue, + }; + entries.push(NormalizedEntry { + timestamp: message_data + .get("meta") + .and_then(|m| { + m.get("sentAt") + }) + .and_then(|s| s.as_u64()) + .map(|ts| ts.to_string()), + entry_type, + content: text.to_string(), + metadata: Some( + content_item.clone(), + ), + }); + } } - } - "thinking" => { - if let Some(thinking) = content_item - .get("thinking") - .and_then(|t| t.as_str()) - { - entries.push(NormalizedEntry { - timestamp: None, - entry_type: - NormalizedEntryType::Thinking, - content: thinking.to_string(), - metadata: Some( - content_item.clone(), - ), - }); + "thinking" => { + if let Some(thinking) = content_item + .get("thinking") + .and_then(|t| t.as_str()) + { + entries.push(NormalizedEntry { + timestamp: None, + entry_type: + NormalizedEntryType::Thinking, + content: thinking.to_string(), + metadata: Some( + content_item.clone(), + ), + }); + } } - } - "tool_use" => { - if let Some(tool_name) = content_item - .get("name") - .and_then(|n| n.as_str()) - { - let input = content_item - .get("input") - .unwrap_or(&Value::Null); - let action_type = self - .extract_action_type( - tool_name, input, - ); - let content = self - .generate_concise_content( - tool_name, - input, - &action_type, - ); + "tool_use" => { + if let Some(tool_name) = content_item + .get("name") + .and_then(|n| n.as_str()) + { + let input = content_item + .get("input") + .unwrap_or(&Value::Null); + let action_type = self + .extract_action_type( + tool_name, input, + ); + let content = self + .generate_concise_content( + tool_name, + input, + &action_type, + ); - entries.push(NormalizedEntry { - timestamp: None, - entry_type: - NormalizedEntryType::ToolUse { - tool_name: tool_name - .to_string(), - action_type, - }, - content, - metadata: Some( - content_item.clone(), - ), - }); + entries.push(NormalizedEntry { + timestamp: None, + entry_type: + NormalizedEntryType::ToolUse { + tool_name: tool_name + .to_string(), + action_type, + }, + content, + metadata: Some( + content_item.clone(), + ), + }); + } } + _ => {} } - _ => {} } } } @@ -206,8 +225,31 @@ impl Executor for AmpExecutor { } } } + true + } + // Ignore these JSON types - they're not relevant for task execution logs + "initial" | "token-usage" | "state" => true, + _ => false, + } + } else { + false + }; + + // If JSON didn't match expected patterns, add it as unrecognized JSON + // Skip JSON with type "result" as requested + if !processed { + if let Some(msg_type) = json.get("type").and_then(|t| t.as_str()) { + if msg_type == "result" { + // Skip result entries + continue; } } + entries.push(NormalizedEntry { + timestamp: None, + entry_type: NormalizedEntryType::SystemMessage, + content: format!("Unrecognized JSON: {}", trimmed), + metadata: Some(json), + }); } } @@ -222,6 +264,26 @@ impl Executor for AmpExecutor { } impl AmpExecutor { + /// Convert absolute paths to relative paths based on current working directory + fn make_path_relative(&self, path: &str) -> String { + let path_obj = Path::new(path); + + // If path is already relative, return as is + if path_obj.is_relative() { + return path.to_string(); + } + + // Try to get current working directory and make path relative to it + if let Ok(current_dir) = std::env::current_dir() { + if let Ok(relative_path) = path_obj.strip_prefix(¤t_dir) { + return relative_path.to_string_lossy().to_string(); + } + } + + // If we can't make it relative, return the original path + path.to_string() + } + fn generate_concise_content( &self, tool_name: &str, @@ -241,7 +303,7 @@ impl AmpExecutor { "todo_write" | "todo_read" => "Managing TODO list".to_string(), "list_directory" | "ls" => { if let Some(path) = input.get("path").and_then(|p| p.as_str()) { - format!("List directory: {}", path) + format!("List directory: {}", self.make_path_relative(path)) } else { "List directory".to_string() } @@ -271,11 +333,11 @@ impl AmpExecutor { "read_file" | "read" => { if let Some(path) = input.get("path").and_then(|p| p.as_str()) { ActionType::FileRead { - path: path.to_string(), + path: self.make_path_relative(path), } } else if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) { ActionType::FileRead { - path: file_path.to_string(), + path: self.make_path_relative(file_path), } } else { ActionType::Other { @@ -286,11 +348,11 @@ impl AmpExecutor { "edit_file" | "write" | "create_file" => { if let Some(path) = input.get("path").and_then(|p| p.as_str()) { ActionType::FileWrite { - path: path.to_string(), + path: self.make_path_relative(path), } } else if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) { ActionType::FileWrite { - path: file_path.to_string(), + path: self.make_path_relative(file_path), } } else { ActionType::Other { @@ -410,9 +472,13 @@ impl Executor for AmpFollowupExecutor { Ok(child) } - fn normalize_logs(&self, logs: &str) -> Result { + fn normalize_logs( + &self, + logs: &str, + worktree_path: &str, + ) -> Result { // Reuse the same logic as the main AmpExecutor let main_executor = AmpExecutor; - main_executor.normalize_logs(logs) + main_executor.normalize_logs(logs, worktree_path) } } diff --git a/backend/src/executors/claude.rs b/backend/src/executors/claude.rs index c154ba0c..5d8626b1 100644 --- a/backend/src/executors/claude.rs +++ b/backend/src/executors/claude.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use async_trait::async_trait; use command_group::{AsyncCommandGroup, AsyncGroupChild}; use tokio::process::Command; @@ -34,18 +36,15 @@ impl Executor for ClaudeExecutor { .await? .ok_or(ExecutorError::TaskNotFound)?; - let prompt = format!( - r#"project_id: {} - - Task title: {} - Task description: {} - "#, - task.project_id, - task.title, - task.description - .as_deref() - .unwrap_or("No description provided") - ); + let prompt = if let Some(task_description) = task.description { + format!( + r#"Task title: {} +Task description: {}"#, + task.title, task_description + ) + } else { + task.title.clone() + }; // Use shell command for cross-platform compatibility let (shell_cmd, shell_arg) = get_shell_command(); @@ -76,7 +75,11 @@ impl Executor for ClaudeExecutor { Ok(child) } - fn normalize_logs(&self, logs: &str) -> Result { + fn normalize_logs( + &self, + logs: &str, + worktree_path: &str, + ) -> Result { use serde_json::Value; let mut entries = Vec::new(); @@ -89,8 +92,19 @@ impl Executor for ClaudeExecutor { } // Try to parse as JSON - let json: Value = serde_json::from_str(trimmed) - .map_err(|e| format!("Failed to parse JSON: {}", e))?; + let json: Value = match serde_json::from_str(trimmed) { + Ok(json) => json, + Err(_) => { + // If line isn't valid JSON, add it as raw text + entries.push(NormalizedEntry { + timestamp: None, + entry_type: NormalizedEntryType::SystemMessage, + content: format!("Raw output: {}", trimmed), + metadata: None, + }); + continue; + } + }; // Extract session ID if session_id.is_none() { @@ -100,7 +114,7 @@ impl Executor for ClaudeExecutor { } // Process different message types - if let Some(msg_type) = json.get("type").and_then(|t| t.as_str()) { + let processed = if let Some(msg_type) = json.get("type").and_then(|t| t.as_str()) { match msg_type { "assistant" => { if let Some(message) = json.get("message") { @@ -133,12 +147,16 @@ impl Executor for ClaudeExecutor { let input = content_item .get("input") .unwrap_or(&Value::Null); - let action_type = - self.extract_action_type(tool_name, input); + let action_type = self.extract_action_type( + tool_name, + input, + worktree_path, + ); let content = self.generate_concise_content( tool_name, input, &action_type, + worktree_path, ); entries.push(NormalizedEntry { @@ -158,6 +176,7 @@ impl Executor for ClaudeExecutor { } } } + true } "user" => { if let Some(message) = json.get("message") { @@ -183,6 +202,7 @@ impl Executor for ClaudeExecutor { } } } + true } "system" => { if let Some(subtype) = json.get("subtype").and_then(|s| s.as_str()) { @@ -200,9 +220,29 @@ impl Executor for ClaudeExecutor { }); } } + true } - _ => {} + _ => false, } + } else { + false + }; + + // If JSON didn't match expected patterns, add it as unrecognized JSON + // Skip JSON with type "result" as requested + if !processed { + if let Some(msg_type) = json.get("type").and_then(|t| t.as_str()) { + if msg_type == "result" { + // Skip result entries + continue; + } + } + entries.push(NormalizedEntry { + timestamp: None, + entry_type: NormalizedEntryType::SystemMessage, + content: format!("Unrecognized JSON: {}", trimmed), + metadata: Some(json), + }); } } @@ -217,11 +257,33 @@ impl Executor for ClaudeExecutor { } impl ClaudeExecutor { + /// Convert absolute paths to relative paths based on worktree path + fn make_path_relative(&self, path: &str, worktree_path: &str) -> String { + let path_obj = Path::new(path); + + tracing::info!("Making path relative: {} -> {}", path, worktree_path); + + // If path is already relative, return as is + if path_obj.is_relative() { + return path.to_string(); + } + + // Try to make path relative to the worktree path + let worktree_path_obj = Path::new(worktree_path); + if let Ok(relative_path) = path_obj.strip_prefix(worktree_path_obj) { + return relative_path.to_string_lossy().to_string(); + } + + // If we can't make it relative, return the original path + path.to_string() + } + fn generate_concise_content( &self, tool_name: &str, input: &serde_json::Value, action_type: &ActionType, + worktree_path: &str, ) -> String { match action_type { ActionType::FileRead { path } => path.clone(), @@ -236,7 +298,10 @@ impl ClaudeExecutor { "todoread" | "todowrite" => "Managing TODO list".to_string(), "ls" => { if let Some(path) = input.get("path").and_then(|p| p.as_str()) { - format!("List directory: {}", path) + format!( + "List directory: {}", + self.make_path_relative(path, worktree_path) + ) } else { "List directory".to_string() } @@ -254,12 +319,17 @@ impl ClaudeExecutor { } } - fn extract_action_type(&self, tool_name: &str, input: &serde_json::Value) -> ActionType { + fn extract_action_type( + &self, + tool_name: &str, + input: &serde_json::Value, + worktree_path: &str, + ) -> ActionType { match tool_name.to_lowercase().as_str() { "read" => { if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) { ActionType::FileRead { - path: file_path.to_string(), + path: self.make_path_relative(file_path, worktree_path), } } else { ActionType::Other { @@ -270,11 +340,11 @@ impl ClaudeExecutor { "edit" | "write" | "multiedit" => { if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) { ActionType::FileWrite { - path: file_path.to_string(), + path: self.make_path_relative(file_path, worktree_path), } } else if let Some(path) = input.get("path").and_then(|p| p.as_str()) { ActionType::FileWrite { - path: path.to_string(), + path: self.make_path_relative(path, worktree_path), } } else { ActionType::Other { @@ -388,9 +458,61 @@ impl Executor for ClaudeFollowupExecutor { Ok(child) } - fn normalize_logs(&self, logs: &str) -> Result { + fn normalize_logs( + &self, + logs: &str, + worktree_path: &str, + ) -> Result { // Reuse the same logic as the main ClaudeExecutor let main_executor = ClaudeExecutor; - main_executor.normalize_logs(logs) + main_executor.normalize_logs(logs, worktree_path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_logs_ignores_result_type() { + let executor = ClaudeExecutor; + let logs = r#"{"type":"system","subtype":"init","cwd":"/private/tmp","session_id":"e988eeea-3712-46a1-82d4-84fbfaa69114","tools":[],"model":"claude-sonnet-4-20250514"} +{"type":"assistant","message":{"id":"msg_123","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Hello world"}],"stop_reason":null},"session_id":"e988eeea-3712-46a1-82d4-84fbfaa69114"} +{"type":"result","subtype":"success","is_error":false,"duration_ms":6059,"result":"Final result"} +{"type":"unknown","data":"some data"}"#; + + let result = executor.normalize_logs(logs, "/tmp/test-worktree").unwrap(); + + // Should have system message, assistant message, and unknown message + // but NOT the result message + assert_eq!(result.entries.len(), 3); + + // Check that no entry contains "result" + for entry in &result.entries { + assert!(!entry.content.contains("result")); + } + + // Check that unknown JSON is still processed + assert!(result + .entries + .iter() + .any(|e| e.content.contains("Unrecognized JSON"))); + } + + #[test] + fn test_make_path_relative() { + let executor = ClaudeExecutor; + + // Test with relative path (should remain unchanged) + assert_eq!( + executor.make_path_relative("src/main.rs", "/tmp/test-worktree"), + "src/main.rs" + ); + + // Test with absolute path (should become relative if possible) + let test_worktree = "/tmp/test-worktree"; + let absolute_path = format!("{}/src/main.rs", test_worktree); + let result = executor.make_path_relative(&absolute_path, test_worktree); + assert_eq!(result, "src/main.rs"); } } diff --git a/backend/src/executors/gemini.rs b/backend/src/executors/gemini.rs index 9aed2d3a..d08a8592 100644 --- a/backend/src/executors/gemini.rs +++ b/backend/src/executors/gemini.rs @@ -35,18 +35,15 @@ impl Executor for GeminiExecutor { .await? .ok_or(ExecutorError::TaskNotFound)?; - let prompt = format!( - r#"project_id: {} - - Task title: {} - Task description: {} - "#, - task.project_id, - task.title, - task.description - .as_deref() - .unwrap_or("No description provided") - ); + let prompt = if let Some(task_description) = task.description { + format!( + r#"Task title: {} +Task description: {}"#, + task.title, task_description + ) + } else { + task.title.clone() + }; // Use shell command for cross-platform compatibility let (shell_cmd, shell_arg) = get_shell_command(); @@ -157,7 +154,11 @@ impl Executor for GeminiExecutor { Ok(child) } - fn normalize_logs(&self, logs: &str) -> Result { + fn normalize_logs( + &self, + logs: &str, + _worktree_path: &str, + ) -> Result { let mut entries: Vec = Vec::new(); let mut parse_errors = Vec::new(); @@ -563,9 +564,13 @@ impl Executor for GeminiFollowupExecutor { Ok(child) } - fn normalize_logs(&self, logs: &str) -> Result { + fn normalize_logs( + &self, + logs: &str, + worktree_path: &str, + ) -> Result { // Reuse the same logic as the main GeminiExecutor let main_executor = GeminiExecutor; - main_executor.normalize_logs(logs) + main_executor.normalize_logs(logs, worktree_path) } } diff --git a/backend/src/executors/opencode.rs b/backend/src/executors/opencode.rs index bea687c8..903b02e6 100644 --- a/backend/src/executors/opencode.rs +++ b/backend/src/executors/opencode.rs @@ -34,18 +34,15 @@ impl Executor for OpencodeExecutor { use tokio::process::Command; - let prompt = format!( - r#"project_id: {} - - Task title: {} - Task description: {} - "#, - task.project_id, - task.title, - task.description - .as_deref() - .unwrap_or("No description provided") - ); + let prompt = if let Some(task_description) = task.description { + format!( + r#"Task title: {} +Task description: {}"#, + task.title, task_description + ) + } else { + task.title.clone() + }; // Use shell command for cross-platform compatibility let (shell_cmd, shell_arg) = get_shell_command(); diff --git a/backend/src/routes/task_attempts.rs b/backend/src/routes/task_attempts.rs index 5a1ba8bd..1d9fb230 100644 --- a/backend/src/routes/task_attempts.rs +++ b/backend/src/routes/task_attempts.rs @@ -1152,8 +1152,11 @@ pub async fn get_execution_process_normalized_logs( let executor = executor_config.create_executor(); + // Path can be a symlink, so resolve it to the real path + let real_path = std::fs::canonicalize(process.working_directory).unwrap(); + // Normalize stdout logs with error handling - match executor.normalize_logs(stdout) { + match executor.normalize_logs(stdout, &real_path.to_string_lossy()) { Ok(normalized) => { stdout_entries = normalized.entries; tracing::debug!( diff --git a/frontend/src/components/tasks/NormalizedConversationViewer.tsx b/frontend/src/components/tasks/NormalizedConversationViewer.tsx index d1a08619..615be582 100644 --- a/frontend/src/components/tasks/NormalizedConversationViewer.tsx +++ b/frontend/src/components/tasks/NormalizedConversationViewer.tsx @@ -212,7 +212,7 @@ export function NormalizedConversationViewer({
{/* Display prompt if available */} {conversation.prompt && ( -
+