Safely parse logs (#82)
* Both executors have been successfully updated to handle log parsing failures gracefully. The changes ensure that:
1. **Invalid JSON lines** are captured as raw text entries instead of causing the entire parsing to fail
2. **Valid JSON with unexpected structure** is captured as unrecognized JSON entries with the raw content preserved
3. **Expected JSON formats** continue to be parsed normally
The key changes made:
- Wrapped JSON parsing in a `match` statement to handle parse errors
- Added logic to track whether JSON was successfully processed
- Added fallback entries for both invalid JSON and unrecognized valid JSON
- All raw/unrecognized content is stored as `SystemMessage` entries with descriptive prefixes
* fixes
* The changes are complete! I've modified the `normalize_logs` method in [`backend/src/executors/amp.rs`](file:///private/var/folders/kr/jdxkcn7129j376nrg0stj9zm0000gn/T/vk-7a88-improve-pa/backend/src/executors/amp.rs#L116-L227) to ignore the specific JSON types you mentioned:
- `{"type":"initial",...}`
- `{"type":"token-usage",...}`
- `{"type":"state",...}`
The parser now uses a match statement to handle different JSON types, setting `processed = true` for the ignored types, which prevents them from being added as "Unrecognized JSON" entries. The build passes successfully.
* Perfect! I have successfully implemented both requested improvements to the parsing of normalized logs for Claude:
## Summary of Changes
**1. Ignoring `{"type":"result",...}` JSON entries:**
- Modified both [`ClaudeExecutor::normalize_logs`](file:///private/var/folders/kr/jdxkcn7129j376nrg0stj9zm0000gn/T/vk-60e3-improve-pa/backend/src/executors/claude.rs#L221-L236) and [`AmpExecutor::normalize_logs`](file:///private/var/folders/kr/jdxkcn7129j376nrg0stj9zm0000gn/T/vk-60e3-improve-pa/backend/src/executors/amp.rs#L227-L242) to skip JSON entries with `type: "result"`
- These entries are now completely ignored and won't appear in the normalized logs
**2. Converting absolute paths to relative paths:**
- Added `make_path_relative` helper functions to both Claude and Amp executors
- Updated path extraction logic in `extract_action_type` methods to use relative paths for file operations
- Updated `generate_concise_content` methods to display relative paths for directory listings
- Paths are now shown relative to the project root, making them more concise
**3. Added comprehensive tests:**
- Created tests to verify that `{"type":"result",...}` entries are properly ignored
- Created tests to verify that absolute paths are converted to relative paths
- All tests pass successfully
The changes maintain backward compatibility while improving the conciseness and readability of the normalized logs.
* Resolve paths in claude
* fmt
* Clippy
This commit is contained in:
committed by
GitHub
parent
37e11824b4
commit
354c6c05ac
@@ -220,7 +220,11 @@ pub trait Executor: Send + Sync {
|
|||||||
) -> Result<command_group::AsyncGroupChild, ExecutorError>;
|
) -> Result<command_group::AsyncGroupChild, ExecutorError>;
|
||||||
|
|
||||||
/// Normalize executor logs into a standard format
|
/// Normalize executor logs into a standard format
|
||||||
fn normalize_logs(&self, _logs: &str) -> Result<NormalizedConversation, String> {
|
fn normalize_logs(
|
||||||
|
&self,
|
||||||
|
_logs: &str,
|
||||||
|
_worktree_path: &str,
|
||||||
|
) -> Result<NormalizedConversation, String> {
|
||||||
// Default implementation returns empty conversation
|
// Default implementation returns empty conversation
|
||||||
Ok(NormalizedConversation {
|
Ok(NormalizedConversation {
|
||||||
entries: vec![],
|
entries: vec![],
|
||||||
|
|||||||
@@ -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":[[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":[]}"#;
|
{"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!(result.executor_type, "amp");
|
||||||
assert_eq!(
|
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":"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"}"#;
|
{"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!(result.executor_type, "claude");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use command_group::{AsyncCommandGroup, AsyncGroupChild};
|
use command_group::{AsyncCommandGroup, AsyncGroupChild};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -37,18 +39,15 @@ impl Executor for AmpExecutor {
|
|||||||
|
|
||||||
use tokio::{io::AsyncWriteExt, process::Command};
|
use tokio::{io::AsyncWriteExt, process::Command};
|
||||||
|
|
||||||
let prompt = format!(
|
let prompt = if let Some(task_description) = task.description {
|
||||||
r#"project_id: {}
|
format!(
|
||||||
|
r#"Task title: {}
|
||||||
Task title: {}
|
Task description: {}"#,
|
||||||
Task description: {}
|
task.title, task_description
|
||||||
"#,
|
)
|
||||||
task.project_id,
|
} else {
|
||||||
task.title,
|
task.title.clone()
|
||||||
task.description
|
};
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("No description provided")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use shell command for cross-platform compatibility
|
// Use shell command for cross-platform compatibility
|
||||||
let (shell_cmd, shell_arg) = get_shell_command();
|
let (shell_cmd, shell_arg) = get_shell_command();
|
||||||
@@ -82,7 +81,11 @@ impl Executor for AmpExecutor {
|
|||||||
Ok(child)
|
Ok(child)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_logs(&self, logs: &str) -> Result<NormalizedConversation, String> {
|
fn normalize_logs(
|
||||||
|
&self,
|
||||||
|
logs: &str,
|
||||||
|
_worktree_path: &str,
|
||||||
|
) -> Result<NormalizedConversation, String> {
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
@@ -95,8 +98,19 @@ impl Executor for AmpExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as JSON
|
// Try to parse as JSON
|
||||||
let json: Value = serde_json::from_str(trimmed)
|
let json: Value = match serde_json::from_str(trimmed) {
|
||||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
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)
|
// Extract session ID (threadID in AMP)
|
||||||
if session_id.is_none() {
|
if session_id.is_none() {
|
||||||
@@ -106,8 +120,9 @@ impl Executor for AmpExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process different message types
|
// 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()) {
|
||||||
if msg_type == "messages" {
|
match msg_type {
|
||||||
|
"messages" => {
|
||||||
if let Some(messages) = json.get("messages").and_then(|m| m.as_array()) {
|
if let Some(messages) = json.get("messages").and_then(|m| m.as_array()) {
|
||||||
for message_entry in messages {
|
for message_entry in messages {
|
||||||
if let Some(message_data) =
|
if let Some(message_data) =
|
||||||
@@ -120,8 +135,9 @@ impl Executor for AmpExecutor {
|
|||||||
message_data.get("content").and_then(|c| c.as_array())
|
message_data.get("content").and_then(|c| c.as_array())
|
||||||
{
|
{
|
||||||
for content_item in content {
|
for content_item in content {
|
||||||
if let Some(content_type) =
|
if let Some(content_type) = content_item
|
||||||
content_item.get("type").and_then(|t| t.as_str())
|
.get("type")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
{
|
{
|
||||||
match content_type {
|
match content_type {
|
||||||
"text" => {
|
"text" => {
|
||||||
@@ -137,7 +153,9 @@ impl Executor for AmpExecutor {
|
|||||||
entries.push(NormalizedEntry {
|
entries.push(NormalizedEntry {
|
||||||
timestamp: message_data
|
timestamp: message_data
|
||||||
.get("meta")
|
.get("meta")
|
||||||
.and_then(|m| m.get("sentAt"))
|
.and_then(|m| {
|
||||||
|
m.get("sentAt")
|
||||||
|
})
|
||||||
.and_then(|s| s.as_u64())
|
.and_then(|s| s.as_u64())
|
||||||
.map(|ts| ts.to_string()),
|
.map(|ts| ts.to_string()),
|
||||||
entry_type,
|
entry_type,
|
||||||
@@ -207,7 +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 {
|
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(
|
fn generate_concise_content(
|
||||||
&self,
|
&self,
|
||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
@@ -241,7 +303,7 @@ impl AmpExecutor {
|
|||||||
"todo_write" | "todo_read" => "Managing TODO list".to_string(),
|
"todo_write" | "todo_read" => "Managing TODO list".to_string(),
|
||||||
"list_directory" | "ls" => {
|
"list_directory" | "ls" => {
|
||||||
if let Some(path) = input.get("path").and_then(|p| p.as_str()) {
|
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 {
|
} else {
|
||||||
"List directory".to_string()
|
"List directory".to_string()
|
||||||
}
|
}
|
||||||
@@ -271,11 +333,11 @@ impl AmpExecutor {
|
|||||||
"read_file" | "read" => {
|
"read_file" | "read" => {
|
||||||
if let Some(path) = input.get("path").and_then(|p| p.as_str()) {
|
if let Some(path) = input.get("path").and_then(|p| p.as_str()) {
|
||||||
ActionType::FileRead {
|
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()) {
|
} else if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) {
|
||||||
ActionType::FileRead {
|
ActionType::FileRead {
|
||||||
path: file_path.to_string(),
|
path: self.make_path_relative(file_path),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ActionType::Other {
|
ActionType::Other {
|
||||||
@@ -286,11 +348,11 @@ impl AmpExecutor {
|
|||||||
"edit_file" | "write" | "create_file" => {
|
"edit_file" | "write" | "create_file" => {
|
||||||
if let Some(path) = input.get("path").and_then(|p| p.as_str()) {
|
if let Some(path) = input.get("path").and_then(|p| p.as_str()) {
|
||||||
ActionType::FileWrite {
|
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()) {
|
} else if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) {
|
||||||
ActionType::FileWrite {
|
ActionType::FileWrite {
|
||||||
path: file_path.to_string(),
|
path: self.make_path_relative(file_path),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ActionType::Other {
|
ActionType::Other {
|
||||||
@@ -410,9 +472,13 @@ impl Executor for AmpFollowupExecutor {
|
|||||||
Ok(child)
|
Ok(child)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_logs(&self, logs: &str) -> Result<NormalizedConversation, String> {
|
fn normalize_logs(
|
||||||
|
&self,
|
||||||
|
logs: &str,
|
||||||
|
worktree_path: &str,
|
||||||
|
) -> Result<NormalizedConversation, String> {
|
||||||
// Reuse the same logic as the main AmpExecutor
|
// Reuse the same logic as the main AmpExecutor
|
||||||
let main_executor = AmpExecutor;
|
let main_executor = AmpExecutor;
|
||||||
main_executor.normalize_logs(logs)
|
main_executor.normalize_logs(logs, worktree_path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use command_group::{AsyncCommandGroup, AsyncGroupChild};
|
use command_group::{AsyncCommandGroup, AsyncGroupChild};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
@@ -34,18 +36,15 @@ impl Executor for ClaudeExecutor {
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(ExecutorError::TaskNotFound)?;
|
.ok_or(ExecutorError::TaskNotFound)?;
|
||||||
|
|
||||||
let prompt = format!(
|
let prompt = if let Some(task_description) = task.description {
|
||||||
r#"project_id: {}
|
format!(
|
||||||
|
r#"Task title: {}
|
||||||
Task title: {}
|
Task description: {}"#,
|
||||||
Task description: {}
|
task.title, task_description
|
||||||
"#,
|
)
|
||||||
task.project_id,
|
} else {
|
||||||
task.title,
|
task.title.clone()
|
||||||
task.description
|
};
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("No description provided")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use shell command for cross-platform compatibility
|
// Use shell command for cross-platform compatibility
|
||||||
let (shell_cmd, shell_arg) = get_shell_command();
|
let (shell_cmd, shell_arg) = get_shell_command();
|
||||||
@@ -76,7 +75,11 @@ impl Executor for ClaudeExecutor {
|
|||||||
Ok(child)
|
Ok(child)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_logs(&self, logs: &str) -> Result<NormalizedConversation, String> {
|
fn normalize_logs(
|
||||||
|
&self,
|
||||||
|
logs: &str,
|
||||||
|
worktree_path: &str,
|
||||||
|
) -> Result<NormalizedConversation, String> {
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
@@ -89,8 +92,19 @@ impl Executor for ClaudeExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as JSON
|
// Try to parse as JSON
|
||||||
let json: Value = serde_json::from_str(trimmed)
|
let json: Value = match serde_json::from_str(trimmed) {
|
||||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
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
|
// Extract session ID
|
||||||
if session_id.is_none() {
|
if session_id.is_none() {
|
||||||
@@ -100,7 +114,7 @@ impl Executor for ClaudeExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process different message types
|
// 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 {
|
match msg_type {
|
||||||
"assistant" => {
|
"assistant" => {
|
||||||
if let Some(message) = json.get("message") {
|
if let Some(message) = json.get("message") {
|
||||||
@@ -133,12 +147,16 @@ impl Executor for ClaudeExecutor {
|
|||||||
let input = content_item
|
let input = content_item
|
||||||
.get("input")
|
.get("input")
|
||||||
.unwrap_or(&Value::Null);
|
.unwrap_or(&Value::Null);
|
||||||
let action_type =
|
let action_type = self.extract_action_type(
|
||||||
self.extract_action_type(tool_name, input);
|
tool_name,
|
||||||
|
input,
|
||||||
|
worktree_path,
|
||||||
|
);
|
||||||
let content = self.generate_concise_content(
|
let content = self.generate_concise_content(
|
||||||
tool_name,
|
tool_name,
|
||||||
input,
|
input,
|
||||||
&action_type,
|
&action_type,
|
||||||
|
worktree_path,
|
||||||
);
|
);
|
||||||
|
|
||||||
entries.push(NormalizedEntry {
|
entries.push(NormalizedEntry {
|
||||||
@@ -158,6 +176,7 @@ impl Executor for ClaudeExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
"user" => {
|
"user" => {
|
||||||
if let Some(message) = json.get("message") {
|
if let Some(message) = json.get("message") {
|
||||||
@@ -183,6 +202,7 @@ impl Executor for ClaudeExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
"system" => {
|
"system" => {
|
||||||
if let Some(subtype) = json.get("subtype").and_then(|s| s.as_str()) {
|
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 {
|
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(
|
fn generate_concise_content(
|
||||||
&self,
|
&self,
|
||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
input: &serde_json::Value,
|
input: &serde_json::Value,
|
||||||
action_type: &ActionType,
|
action_type: &ActionType,
|
||||||
|
worktree_path: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
match action_type {
|
match action_type {
|
||||||
ActionType::FileRead { path } => path.clone(),
|
ActionType::FileRead { path } => path.clone(),
|
||||||
@@ -236,7 +298,10 @@ impl ClaudeExecutor {
|
|||||||
"todoread" | "todowrite" => "Managing TODO list".to_string(),
|
"todoread" | "todowrite" => "Managing TODO list".to_string(),
|
||||||
"ls" => {
|
"ls" => {
|
||||||
if let Some(path) = input.get("path").and_then(|p| p.as_str()) {
|
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 {
|
} else {
|
||||||
"List directory".to_string()
|
"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() {
|
match tool_name.to_lowercase().as_str() {
|
||||||
"read" => {
|
"read" => {
|
||||||
if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) {
|
if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) {
|
||||||
ActionType::FileRead {
|
ActionType::FileRead {
|
||||||
path: file_path.to_string(),
|
path: self.make_path_relative(file_path, worktree_path),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ActionType::Other {
|
ActionType::Other {
|
||||||
@@ -270,11 +340,11 @@ impl ClaudeExecutor {
|
|||||||
"edit" | "write" | "multiedit" => {
|
"edit" | "write" | "multiedit" => {
|
||||||
if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) {
|
if let Some(file_path) = input.get("file_path").and_then(|p| p.as_str()) {
|
||||||
ActionType::FileWrite {
|
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()) {
|
} else if let Some(path) = input.get("path").and_then(|p| p.as_str()) {
|
||||||
ActionType::FileWrite {
|
ActionType::FileWrite {
|
||||||
path: path.to_string(),
|
path: self.make_path_relative(path, worktree_path),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ActionType::Other {
|
ActionType::Other {
|
||||||
@@ -388,9 +458,61 @@ impl Executor for ClaudeFollowupExecutor {
|
|||||||
Ok(child)
|
Ok(child)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_logs(&self, logs: &str) -> Result<NormalizedConversation, String> {
|
fn normalize_logs(
|
||||||
|
&self,
|
||||||
|
logs: &str,
|
||||||
|
worktree_path: &str,
|
||||||
|
) -> Result<NormalizedConversation, String> {
|
||||||
// Reuse the same logic as the main ClaudeExecutor
|
// Reuse the same logic as the main ClaudeExecutor
|
||||||
let main_executor = 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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,18 +35,15 @@ impl Executor for GeminiExecutor {
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(ExecutorError::TaskNotFound)?;
|
.ok_or(ExecutorError::TaskNotFound)?;
|
||||||
|
|
||||||
let prompt = format!(
|
let prompt = if let Some(task_description) = task.description {
|
||||||
r#"project_id: {}
|
format!(
|
||||||
|
r#"Task title: {}
|
||||||
Task title: {}
|
Task description: {}"#,
|
||||||
Task description: {}
|
task.title, task_description
|
||||||
"#,
|
)
|
||||||
task.project_id,
|
} else {
|
||||||
task.title,
|
task.title.clone()
|
||||||
task.description
|
};
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("No description provided")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use shell command for cross-platform compatibility
|
// Use shell command for cross-platform compatibility
|
||||||
let (shell_cmd, shell_arg) = get_shell_command();
|
let (shell_cmd, shell_arg) = get_shell_command();
|
||||||
@@ -157,7 +154,11 @@ impl Executor for GeminiExecutor {
|
|||||||
Ok(child)
|
Ok(child)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_logs(&self, logs: &str) -> Result<NormalizedConversation, String> {
|
fn normalize_logs(
|
||||||
|
&self,
|
||||||
|
logs: &str,
|
||||||
|
_worktree_path: &str,
|
||||||
|
) -> Result<NormalizedConversation, String> {
|
||||||
let mut entries: Vec<NormalizedEntry> = Vec::new();
|
let mut entries: Vec<NormalizedEntry> = Vec::new();
|
||||||
let mut parse_errors = Vec::new();
|
let mut parse_errors = Vec::new();
|
||||||
|
|
||||||
@@ -563,9 +564,13 @@ impl Executor for GeminiFollowupExecutor {
|
|||||||
Ok(child)
|
Ok(child)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_logs(&self, logs: &str) -> Result<NormalizedConversation, String> {
|
fn normalize_logs(
|
||||||
|
&self,
|
||||||
|
logs: &str,
|
||||||
|
worktree_path: &str,
|
||||||
|
) -> Result<NormalizedConversation, String> {
|
||||||
// Reuse the same logic as the main GeminiExecutor
|
// Reuse the same logic as the main GeminiExecutor
|
||||||
let main_executor = GeminiExecutor;
|
let main_executor = GeminiExecutor;
|
||||||
main_executor.normalize_logs(logs)
|
main_executor.normalize_logs(logs, worktree_path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,18 +34,15 @@ impl Executor for OpencodeExecutor {
|
|||||||
|
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
let prompt = format!(
|
let prompt = if let Some(task_description) = task.description {
|
||||||
r#"project_id: {}
|
format!(
|
||||||
|
r#"Task title: {}
|
||||||
Task title: {}
|
Task description: {}"#,
|
||||||
Task description: {}
|
task.title, task_description
|
||||||
"#,
|
)
|
||||||
task.project_id,
|
} else {
|
||||||
task.title,
|
task.title.clone()
|
||||||
task.description
|
};
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("No description provided")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use shell command for cross-platform compatibility
|
// Use shell command for cross-platform compatibility
|
||||||
let (shell_cmd, shell_arg) = get_shell_command();
|
let (shell_cmd, shell_arg) = get_shell_command();
|
||||||
|
|||||||
@@ -1152,8 +1152,11 @@ pub async fn get_execution_process_normalized_logs(
|
|||||||
|
|
||||||
let executor = executor_config.create_executor();
|
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
|
// Normalize stdout logs with error handling
|
||||||
match executor.normalize_logs(stdout) {
|
match executor.normalize_logs(stdout, &real_path.to_string_lossy()) {
|
||||||
Ok(normalized) => {
|
Ok(normalized) => {
|
||||||
stdout_entries = normalized.entries;
|
stdout_entries = normalized.entries;
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export function NormalizedConversationViewer({
|
|||||||
<div>
|
<div>
|
||||||
{/* Display prompt if available */}
|
{/* Display prompt if available */}
|
||||||
{conversation.prompt && (
|
{conversation.prompt && (
|
||||||
<div className="flex items-start gap-3 mb-6">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex-shrink-0 mt-1">
|
<div className="flex-shrink-0 mt-1">
|
||||||
<Hammer className="h-4 w-4 text-blue-600" />
|
<Hammer className="h-4 w-4 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user