Merge branch 'main' into cline
This commit is contained in:
@@ -514,10 +514,11 @@ async fn run_connection(
|
||||
let conn_id = conn_id.clone();
|
||||
let handle = handle.clone();
|
||||
let perms = perms.clone();
|
||||
let perm_cwd = cwd_string.clone();
|
||||
async move |req: RequestPermissionRequest,
|
||||
responder: Responder<RequestPermissionResponse>,
|
||||
_cx: ConnectionTo<Agent>| {
|
||||
handle_permission_request(&conn_id, &handle, &perms, req, responder).await;
|
||||
handle_permission_request(&conn_id, &handle, &perms, &perm_cwd, req, responder).await;
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
@@ -689,7 +690,7 @@ async fn run_connection(
|
||||
notif.update,
|
||||
SessionUpdate::AvailableCommandsUpdate(_)
|
||||
) {
|
||||
emit_conversation_update(&cid, &h, notif.update);
|
||||
emit_conversation_update(&cid, &h, notif.update, None);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -870,6 +871,7 @@ async fn handle_permission_request(
|
||||
conn_id: &str,
|
||||
handle: &tauri::AppHandle,
|
||||
perms: &PendingPermissions,
|
||||
cwd: &str,
|
||||
req: RequestPermissionRequest,
|
||||
responder: Responder<RequestPermissionResponse>,
|
||||
) {
|
||||
@@ -891,7 +893,38 @@ async fn handle_permission_request(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tool_call_value = serde_json::to_value(&req.tool_call).unwrap_or_default();
|
||||
let mut tool_call_value = serde_json::to_value(&req.tool_call).unwrap_or_default();
|
||||
|
||||
// Resolve line numbers in rawInput for edit tool permission requests
|
||||
if let Some(obj) = tool_call_value.as_object_mut() {
|
||||
let key = ["rawInput", "raw_input"]
|
||||
.into_iter()
|
||||
.find(|k| obj.contains_key(*k));
|
||||
if let Some(key) = key {
|
||||
match obj.get_mut(key) {
|
||||
// rawInput is a JSON object: inject _start_line in place
|
||||
Some(v) if v.is_object() => {
|
||||
inject_start_line(v, Some(cwd));
|
||||
}
|
||||
// rawInput is a JSON string: parse, inject, write back as object
|
||||
Some(serde_json::Value::String(text)) => {
|
||||
let text = text.clone();
|
||||
if let Ok(mut parsed) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
if inject_start_line(&mut parsed, Some(cwd)) {
|
||||
obj.insert(key.to_string(), parsed);
|
||||
}
|
||||
} else if text.contains("@@\n") || text.contains("@@\r\n") {
|
||||
if let Some(resolved) =
|
||||
crate::parsers::resolve_patch_text(&text, Some(cwd))
|
||||
{
|
||||
obj.insert(key.to_string(), serde_json::Value::String(resolved));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
perms.lock().await.insert(request_id.clone(), responder);
|
||||
|
||||
@@ -1437,10 +1470,11 @@ async fn run_conversation_loop<'a>(
|
||||
Ok(SessionMessage::SessionMessage(dispatch)) => {
|
||||
let cid = conn_id.to_string();
|
||||
let h = handle.clone();
|
||||
let cwd_opt = Some(cwd);
|
||||
let _ = MatchDispatch::new(dispatch)
|
||||
.if_notification(
|
||||
async |notif: SessionNotification| {
|
||||
emit_conversation_update(&cid, &h, notif.update);
|
||||
emit_conversation_update(&cid, &h, notif.update, cwd_opt);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
@@ -1520,6 +1554,7 @@ async fn run_conversation_loop<'a>(
|
||||
let h = handle.clone();
|
||||
let runtime = terminal_runtime.clone();
|
||||
let session_id = sid.clone();
|
||||
let cwd_opt = Some(cwd);
|
||||
if let Err(e) = MatchDispatch::new(dispatch)
|
||||
.if_notification(
|
||||
async |notif: SessionNotification| {
|
||||
@@ -1527,7 +1562,7 @@ async fn run_conversation_loop<'a>(
|
||||
¬if.update,
|
||||
&mut tracked_terminal_tool_calls,
|
||||
);
|
||||
emit_conversation_update(&cid, &h, notif.update);
|
||||
emit_conversation_update(&cid, &h, notif.update, cwd_opt);
|
||||
if should_poll_now {
|
||||
poll_tracked_terminal_tool_calls(
|
||||
runtime.as_ref(),
|
||||
@@ -1894,6 +1929,71 @@ fn serialize_tool_call_content(content: &[ToolCallContent]) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// If the output looks like numbered lines (` 115→content`), strip them
|
||||
/// and return `{"start_line":N,"content":"..."}` — same as the historical path.
|
||||
fn structurize_live_output(text: &str) -> String {
|
||||
if let Some(json) = crate::parsers::strip_numbered_lines(text) {
|
||||
return json;
|
||||
}
|
||||
text.to_string()
|
||||
}
|
||||
|
||||
/// Resolve line numbers for live tool call input.
|
||||
///
|
||||
/// Resolve line numbers for live tool call input (string form).
|
||||
///
|
||||
/// - For apply_patch with bare `@@`: resolve line numbers in place.
|
||||
/// - For canonical edit JSON: inject `_start_line`.
|
||||
fn resolve_live_tool_input(text: &str, cwd: Option<&str>) -> String {
|
||||
if text.contains("@@\n") || text.contains("@@\r\n") {
|
||||
if let Some(resolved) = crate::parsers::resolve_patch_text(text, cwd) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
if let Ok(mut parsed) = serde_json::from_str::<serde_json::Value>(text) {
|
||||
if inject_start_line(&mut parsed, cwd) {
|
||||
return parsed.to_string();
|
||||
}
|
||||
}
|
||||
text.to_string()
|
||||
}
|
||||
|
||||
/// Try to inject `_start_line` into a JSON object with `file_path` + `old_string`.
|
||||
/// Returns true if injected.
|
||||
fn inject_start_line(value: &mut serde_json::Value, cwd: Option<&str>) -> bool {
|
||||
let obj = match value.as_object_mut() {
|
||||
Some(o) => o,
|
||||
None => return false,
|
||||
};
|
||||
let fp = obj
|
||||
.get("file_path")
|
||||
.or_else(|| obj.get("path"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let old_str = obj
|
||||
.get("old_string")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
if let (Some(fp), Some(old_str)) = (fp, old_str) {
|
||||
if let Some(sl) = find_string_start_line(&fp, &old_str, cwd) {
|
||||
obj.insert("_start_line".to_string(), serde_json::json!(sl));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Find the 1-based start line of `needle` in the file at `path`.
|
||||
fn find_string_start_line(path: &str, needle: &str, cwd: Option<&str>) -> Option<u64> {
|
||||
if needle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let file_lines = crate::parsers::load_file_lines(path, cwd)?;
|
||||
let file_content = file_lines.join("\n");
|
||||
let byte_offset = file_content.find(needle)?;
|
||||
Some(file_content[..byte_offset].matches('\n').count() as u64 + 1)
|
||||
}
|
||||
|
||||
fn json_value_to_text(val: &Option<serde_json::Value>) -> Option<String> {
|
||||
match val {
|
||||
Some(serde_json::Value::String(text)) => Some(text.clone()),
|
||||
@@ -1938,6 +2038,7 @@ fn emit_conversation_update(
|
||||
connection_id: &str,
|
||||
app_handle: &tauri::AppHandle,
|
||||
update: SessionUpdate,
|
||||
cwd: Option<&str>,
|
||||
) {
|
||||
match update {
|
||||
SessionUpdate::UserMessageChunk(_) => {
|
||||
@@ -1978,8 +2079,10 @@ fn emit_conversation_update(
|
||||
}
|
||||
SessionUpdate::ToolCall(tc) => {
|
||||
let content = serialize_tool_call_content(&tc.content);
|
||||
let raw_input = json_value_to_text(&tc.raw_input);
|
||||
let raw_output = json_value_to_text(&tc.raw_output);
|
||||
let raw_input = json_value_to_text(&tc.raw_input)
|
||||
.map(|text| resolve_live_tool_input(&text, cwd));
|
||||
let raw_output = json_value_to_text(&tc.raw_output)
|
||||
.map(|text| structurize_live_output(&text));
|
||||
crate::web::event_bridge::emit_event(
|
||||
app_handle,
|
||||
"acp://event",
|
||||
@@ -2001,8 +2104,10 @@ fn emit_conversation_update(
|
||||
.content
|
||||
.as_deref()
|
||||
.and_then(serialize_tool_call_content);
|
||||
let raw_input = json_value_to_text(&tcu.fields.raw_input);
|
||||
let raw_output = json_value_to_text(&tcu.fields.raw_output);
|
||||
let raw_input = json_value_to_text(&tcu.fields.raw_input)
|
||||
.map(|text| resolve_live_tool_input(&text, cwd));
|
||||
let raw_output = json_value_to_text(&tcu.fields.raw_output)
|
||||
.map(|text| structurize_live_output(&text));
|
||||
crate::web::event_bridge::emit_event(
|
||||
app_handle,
|
||||
"acp://event",
|
||||
|
||||
@@ -53,6 +53,46 @@ fn strip_system_tags(text: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// Check if a JSONL entry is a system meta message (isMeta: true).
|
||||
/// Rebuild a standard unified diff from `toolUseResult.structuredPatch`.
|
||||
///
|
||||
/// Each hunk in `structuredPatch` has `oldStart`, `oldLines`, `newStart`,
|
||||
/// `newLines`, and `lines` (prefixed with ` `, `+`, or `-`).
|
||||
fn rebuild_diff_from_structured_patch(
|
||||
file_path: &str,
|
||||
structured_patch: &serde_json::Value,
|
||||
) -> Option<String> {
|
||||
let hunks = structured_patch.as_array()?;
|
||||
if hunks.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut output = String::new();
|
||||
output.push_str(&format!("--- a/{}\n+++ b/{}\n", file_path, file_path));
|
||||
|
||||
for hunk in hunks {
|
||||
let old_start = hunk.get("oldStart").and_then(|v| v.as_u64()).unwrap_or(1);
|
||||
let old_lines = hunk.get("oldLines").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let new_start = hunk.get("newStart").and_then(|v| v.as_u64()).unwrap_or(1);
|
||||
let new_lines = hunk.get("newLines").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
|
||||
output.push_str(&format!(
|
||||
"@@ -{},{} +{},{} @@\n",
|
||||
old_start, old_lines, new_start, new_lines
|
||||
));
|
||||
|
||||
if let Some(lines) = hunk.get("lines").and_then(|v| v.as_array()) {
|
||||
for line in lines {
|
||||
if let Some(text) = line.as_str() {
|
||||
output.push_str(text);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(output)
|
||||
}
|
||||
|
||||
fn is_meta_message(value: &serde_json::Value) -> bool {
|
||||
value
|
||||
.get("isMeta")
|
||||
@@ -489,7 +529,7 @@ impl ClaudeParser {
|
||||
continue;
|
||||
}
|
||||
"user" => {
|
||||
let content = extract_user_content(&value);
|
||||
let mut content = extract_user_content(&value);
|
||||
|
||||
// Skip user messages that are empty after system tag stripping
|
||||
if content.is_empty() {
|
||||
@@ -518,6 +558,31 @@ impl ClaudeParser {
|
||||
MessageRole::User
|
||||
};
|
||||
|
||||
// Check toolUseResult.structuredPatch for real line numbers
|
||||
if let Some(tur) = value.get("toolUseResult") {
|
||||
if let Some(sp) = tur.get("structuredPatch") {
|
||||
let fp = tur
|
||||
.get("filePath")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("file");
|
||||
if let Some(diff) = rebuild_diff_from_structured_patch(fp, sp) {
|
||||
// Find the matching ToolResult in this user message's content
|
||||
// and replace its output_preview with the real diff
|
||||
for block in content.iter_mut() {
|
||||
if let ContentBlock::ToolResult {
|
||||
ref mut output_preview,
|
||||
is_error: false,
|
||||
..
|
||||
} = block
|
||||
{
|
||||
*output_preview = Some(diff.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages.push(UnifiedMessage {
|
||||
id: uuid,
|
||||
role,
|
||||
@@ -574,6 +639,160 @@ impl ClaudeParser {
|
||||
}
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
// Top-level tool_use record (Claude Code JSONL format)
|
||||
let timestamp = parse_timestamp(&value).unwrap_or_else(Utc::now);
|
||||
let tool_name = value
|
||||
.get("tool_name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let input_preview = value.get("tool_input").map(|i| i.to_string());
|
||||
let synthetic_id = format!("tl-tool-{}", messages.len());
|
||||
|
||||
// Attach to last assistant message, or create a synthetic one
|
||||
if let Some(last) = messages
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|m| matches!(m.role, MessageRole::Assistant))
|
||||
{
|
||||
last.content.push(ContentBlock::ToolUse {
|
||||
tool_use_id: Some(synthetic_id),
|
||||
tool_name,
|
||||
input_preview,
|
||||
});
|
||||
} else {
|
||||
messages.push(UnifiedMessage {
|
||||
id: format!("synth-assistant-{}", messages.len()),
|
||||
role: MessageRole::Assistant,
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
tool_use_id: Some(synthetic_id),
|
||||
tool_name,
|
||||
input_preview,
|
||||
}],
|
||||
timestamp,
|
||||
usage: None,
|
||||
duration_ms: None,
|
||||
model: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
"tool_result" => {
|
||||
// Top-level tool_result record (Claude Code JSONL format)
|
||||
let tool_output = value.get("tool_output");
|
||||
let tool_name = value
|
||||
.get("tool_name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("");
|
||||
let is_error = tool_output
|
||||
.and_then(|o| o.get("exit"))
|
||||
.and_then(|e| e.as_i64())
|
||||
.is_some_and(|code| code != 0);
|
||||
|
||||
// Extract output text: prefer "preview" (read), then "output" (bash)
|
||||
let output_text = tool_output
|
||||
.and_then(|o| {
|
||||
o.get("preview")
|
||||
.or_else(|| o.get("output"))
|
||||
.and_then(|v| v.as_str())
|
||||
})
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Don't structurize here — `structurize_read_tool_output`
|
||||
// will handle Read tool output uniformly after grouping.
|
||||
let output_preview = output_text;
|
||||
|
||||
// Find the matching ToolUse by tool_name (reverse scan so the
|
||||
// most recent match wins), then fall back to the last ToolUse
|
||||
// without a paired ToolResult yet.
|
||||
let existing_result_ids: std::collections::HashSet<String> = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.map(|m| {
|
||||
m.content
|
||||
.iter()
|
||||
.filter_map(|b| {
|
||||
if let ContentBlock::ToolResult {
|
||||
tool_use_id: Some(ref id),
|
||||
..
|
||||
} = b
|
||||
{
|
||||
Some(id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let matching_id = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.and_then(|m| {
|
||||
// First: try to find an unpaired ToolUse with the same tool_name
|
||||
let by_name = m.content.iter().rev().find_map(|b| {
|
||||
if let ContentBlock::ToolUse {
|
||||
tool_use_id: Some(ref id),
|
||||
tool_name: ref tn,
|
||||
..
|
||||
} = b
|
||||
{
|
||||
if tn == tool_name
|
||||
&& !existing_result_ids.contains(id)
|
||||
{
|
||||
return Some(id.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
if by_name.is_some() {
|
||||
return by_name;
|
||||
}
|
||||
// Fallback: last unpaired ToolUse regardless of name
|
||||
m.content.iter().rev().find_map(|b| {
|
||||
if let ContentBlock::ToolUse {
|
||||
tool_use_id: Some(ref id),
|
||||
..
|
||||
} = b
|
||||
{
|
||||
if !existing_result_ids.contains(id) {
|
||||
return Some(id.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
});
|
||||
|
||||
// Append ToolResult to the same assistant message so they stay in the same turn
|
||||
if let Some(last) = messages
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|m| matches!(m.role, MessageRole::Assistant))
|
||||
{
|
||||
last.content.push(ContentBlock::ToolResult {
|
||||
tool_use_id: matching_id,
|
||||
output_preview,
|
||||
is_error,
|
||||
});
|
||||
} else {
|
||||
messages.push(UnifiedMessage {
|
||||
id: format!("synth-result-{}", messages.len()),
|
||||
role: MessageRole::Assistant,
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: matching_id,
|
||||
output_preview,
|
||||
is_error,
|
||||
}],
|
||||
timestamp: parse_timestamp(&value).unwrap_or_else(Utc::now),
|
||||
usage: None,
|
||||
duration_ms: None,
|
||||
model: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -583,6 +802,8 @@ impl ClaudeParser {
|
||||
|
||||
let mut turns = group_into_turns(messages);
|
||||
super::relocate_orphaned_tool_results(&mut turns);
|
||||
super::structurize_read_tool_output(&mut turns);
|
||||
super::resolve_patch_line_numbers(&mut turns, cwd.as_deref());
|
||||
let context_window_used_tokens = latest_claude_context_window_used_tokens(&turns);
|
||||
let context_window_max_tokens =
|
||||
claude_context_window_max_tokens_for_model(model.as_deref());
|
||||
|
||||
@@ -773,6 +773,8 @@ impl CodexParser {
|
||||
|
||||
let mut turns = group_into_turns(messages);
|
||||
super::relocate_orphaned_tool_results(&mut turns);
|
||||
super::structurize_read_tool_output(&mut turns);
|
||||
super::resolve_patch_line_numbers(&mut turns, cwd.as_deref());
|
||||
let mut session_stats = super::compute_session_stats(&turns);
|
||||
session_stats =
|
||||
merge_codex_total_usage_stats(session_stats, latest_total_usage, latest_total_tokens);
|
||||
|
||||
@@ -556,6 +556,8 @@ impl GeminiParser {
|
||||
|
||||
let mut turns = group_into_turns(messages);
|
||||
super::relocate_orphaned_tool_results(&mut turns);
|
||||
super::structurize_read_tool_output(&mut turns);
|
||||
super::resolve_patch_line_numbers(&mut turns, summary.folder_path.as_deref());
|
||||
summary.message_count = turns.len() as u32;
|
||||
summary.id = conversation_id.to_string();
|
||||
let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns);
|
||||
|
||||
@@ -5,7 +5,7 @@ pub mod gemini;
|
||||
pub mod openclaw;
|
||||
pub mod opencode;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use regex::Regex;
|
||||
@@ -293,6 +293,355 @@ pub fn relocate_orphaned_tool_results(turns: &mut Vec<MessageTurn>) {
|
||||
turns.retain(|turn| !turn.blocks.is_empty());
|
||||
}
|
||||
|
||||
/// Convert Read tool output from numbered-line format to `{"start_line":N,"content":"..."}`.
|
||||
///
|
||||
/// Claude Code embeds line numbers in Read output like ` 115→content`.
|
||||
/// This splits on the `→` delimiter (or tab for older `cat -n` format),
|
||||
/// extracts the starting line number, and returns clean content.
|
||||
pub fn structurize_read_tool_output(turns: &mut [MessageTurn]) {
|
||||
let mut read_tool_ids: HashSet<String> = HashSet::new();
|
||||
for turn in turns.iter() {
|
||||
for block in &turn.blocks {
|
||||
if let ContentBlock::ToolUse {
|
||||
tool_use_id: Some(ref id),
|
||||
ref tool_name,
|
||||
..
|
||||
} = block
|
||||
{
|
||||
let name = tool_name.to_lowercase();
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
"read" | "read_file" | "readfile" | "read file" | "cat" | "view"
|
||||
) {
|
||||
read_tool_ids.insert(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for turn in turns.iter_mut() {
|
||||
for block in turn.blocks.iter_mut() {
|
||||
let is_read_result = matches!(
|
||||
block,
|
||||
ContentBlock::ToolResult { tool_use_id: Some(ref id), .. }
|
||||
if read_tool_ids.contains(id)
|
||||
);
|
||||
if !is_read_result {
|
||||
continue;
|
||||
}
|
||||
if let ContentBlock::ToolResult {
|
||||
ref mut output_preview,
|
||||
..
|
||||
} = block
|
||||
{
|
||||
if let Some(ref text) = output_preview {
|
||||
if let Some(json) = strip_numbered_lines(text) {
|
||||
*output_preview = Some(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Known delimiters between line number and content.
|
||||
const LINE_NUM_DELIMITERS: &[&str] = &["→", "\t"];
|
||||
|
||||
/// Try to split a line at a known delimiter, returning (line_number, content).
|
||||
fn split_line_number(line: &str) -> Option<(u64, &str)> {
|
||||
for delim in LINE_NUM_DELIMITERS {
|
||||
if let Some(pos) = line.find(delim) {
|
||||
let prefix = line[..pos].trim();
|
||||
if let Ok(num) = prefix.parse::<u64>() {
|
||||
let content_start = pos + delim.len();
|
||||
return Some((num, &line[content_start..]));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// If most lines have a recognized line-number prefix, strip them all
|
||||
/// and return `{"start_line":N,"content":"clean text"}`.
|
||||
pub fn strip_numbered_lines(text: &str) -> Option<String> {
|
||||
let raw_lines: Vec<&str> = text.lines().collect();
|
||||
if raw_lines.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let matched = raw_lines
|
||||
.iter()
|
||||
.filter(|l| l.is_empty() || split_line_number(l).is_some())
|
||||
.count();
|
||||
if matched < raw_lines.len() * 4 / 5 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut start_line: u64 = 1;
|
||||
let mut first = true;
|
||||
let stripped: Vec<&str> = raw_lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
if let Some((num, content)) = split_line_number(line) {
|
||||
if first {
|
||||
start_line = num;
|
||||
first = false;
|
||||
}
|
||||
content
|
||||
} else {
|
||||
first = false;
|
||||
*line
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(
|
||||
serde_json::json!({
|
||||
"start_line": start_line,
|
||||
"content": stripped.join("\n")
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve line numbers for `*** Update File` / `*** Add File` style patches.
|
||||
///
|
||||
/// When a hunk header is just `@@` without `-N,M +N,M`, this reads the actual
|
||||
/// file from disk and matches the context lines to calculate real line numbers.
|
||||
/// Falls back gracefully if the file doesn't exist or context doesn't match.
|
||||
pub fn resolve_patch_line_numbers(turns: &mut [MessageTurn], cwd: Option<&str>) {
|
||||
for turn in turns.iter_mut() {
|
||||
for block in turn.blocks.iter_mut() {
|
||||
if let ContentBlock::ToolUse {
|
||||
ref tool_name,
|
||||
ref mut input_preview,
|
||||
..
|
||||
} = block
|
||||
{
|
||||
let name = tool_name.to_lowercase();
|
||||
if !matches!(
|
||||
name.as_str(),
|
||||
"apply_patch" | "edit" | "patch" | "applypatch"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if let Some(ref text) = input_preview {
|
||||
if text.contains("@@\n") || text.contains("@@\r\n") {
|
||||
if let Some(resolved) = resolve_patch_text(text, cwd) {
|
||||
*input_preview = Some(resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a single patch text, replacing bare `@@` with `@@ -N,M +N,M @@`.
|
||||
pub fn resolve_patch_text(patch: &str, cwd: Option<&str>) -> Option<String> {
|
||||
let mut output = String::with_capacity(patch.len() + 256);
|
||||
let mut current_file_path: Option<String> = None;
|
||||
let mut file_lines: Option<Vec<String>> = None;
|
||||
let mut any_resolved = false;
|
||||
|
||||
let lines: Vec<&str> = patch.lines().collect();
|
||||
let mut i = 0;
|
||||
|
||||
while i < lines.len() {
|
||||
let line = lines[i];
|
||||
|
||||
// Detect file markers
|
||||
if line.starts_with("*** Update File: ") || line.starts_with("*** Add File: ") {
|
||||
let marker_end = if line.starts_with("*** Update File: ") {
|
||||
17
|
||||
} else {
|
||||
14
|
||||
};
|
||||
let path = line[marker_end..].trim();
|
||||
current_file_path = Some(path.to_string());
|
||||
file_lines = load_file_lines(path, cwd);
|
||||
output.push_str(line);
|
||||
output.push('\n');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect bare @@ hunk header (no line numbers)
|
||||
if line == "@@" {
|
||||
if let (Some(ref fl), true) = (&file_lines, current_file_path.is_some()) {
|
||||
// Collect context lines from this hunk to find match position
|
||||
let hunk_lines = collect_hunk_lines(&lines, i + 1);
|
||||
if let Some((old_start, old_count, new_count)) =
|
||||
find_hunk_position(fl, &hunk_lines)
|
||||
{
|
||||
let new_start = old_start; // same start for context-based patches
|
||||
output.push_str(&format!(
|
||||
"@@ -{},{} +{},{} @@\n",
|
||||
old_start, old_count, new_start, new_count
|
||||
));
|
||||
any_resolved = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Fallback: keep bare @@
|
||||
output.push_str(line);
|
||||
output.push('\n');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push_str(line);
|
||||
output.push('\n');
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if any_resolved { Some(output) } else { None }
|
||||
}
|
||||
|
||||
/// Load file lines from disk, trying both absolute path and cwd-relative.
|
||||
pub fn load_file_lines(path: &str, cwd: Option<&str>) -> Option<Vec<String>> {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
let p = Path::new(path);
|
||||
if p.is_absolute() {
|
||||
if let Ok(content) = fs::read_to_string(p) {
|
||||
return Some(content.lines().map(|l| l.to_string()).collect());
|
||||
}
|
||||
}
|
||||
if let Some(base) = cwd {
|
||||
let full = Path::new(base).join(path);
|
||||
if let Ok(content) = fs::read_to_string(&full) {
|
||||
return Some(content.lines().map(|l| l.to_string()).collect());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Collect lines belonging to a hunk (until next `@@` or `*** ` marker or end).
|
||||
fn collect_hunk_lines<'a>(lines: &'a [&'a str], start: usize) -> Vec<&'a str> {
|
||||
let mut result = Vec::new();
|
||||
for &line in &lines[start..] {
|
||||
if line == "@@"
|
||||
|| line.starts_with("*** ")
|
||||
{
|
||||
break;
|
||||
}
|
||||
result.push(line);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Find where a hunk's context lines match in the file, returning (start_line, old_count, new_count).
|
||||
/// `start_line` is 1-based.
|
||||
///
|
||||
/// The file on disk may be in either pre-patch or post-patch state, and may
|
||||
/// have been further modified. We try three strategies in order:
|
||||
/// 1. Contiguous match of context+added lines (post-patch file, no further edits)
|
||||
/// 2. Contiguous match of context+deleted lines (pre-patch file)
|
||||
/// 3. Subsequence match of context-only lines (file has been further modified)
|
||||
fn find_hunk_position(
|
||||
file_lines: &[String],
|
||||
hunk_lines: &[&str],
|
||||
) -> Option<(usize, usize, usize)> {
|
||||
let mut old_count = 0usize;
|
||||
let mut new_count = 0usize;
|
||||
for hl in hunk_lines {
|
||||
if hl.starts_with(' ') {
|
||||
old_count += 1;
|
||||
new_count += 1;
|
||||
} else if hl.starts_with('-') {
|
||||
old_count += 1;
|
||||
} else if hl.starts_with('+') {
|
||||
new_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 1: contiguous match of context+added (post-patch)
|
||||
let new_view: Vec<&str> = hunk_lines
|
||||
.iter()
|
||||
.filter(|l| l.starts_with(' ') || l.starts_with('+'))
|
||||
.map(|l| &l[1..])
|
||||
.collect();
|
||||
if let Some(pos) = find_contiguous(file_lines, &new_view) {
|
||||
return Some((pos + 1, old_count, new_count));
|
||||
}
|
||||
|
||||
// Strategy 2: contiguous match of context+deleted (pre-patch)
|
||||
let old_view: Vec<&str> = hunk_lines
|
||||
.iter()
|
||||
.filter(|l| l.starts_with(' ') || l.starts_with('-'))
|
||||
.map(|l| &l[1..])
|
||||
.collect();
|
||||
if let Some(pos) = find_contiguous(file_lines, &old_view) {
|
||||
return Some((pos + 1, old_count, new_count));
|
||||
}
|
||||
|
||||
// Strategy 3: subsequence match of context-only lines (file further modified)
|
||||
let ctx_only: Vec<&str> = hunk_lines
|
||||
.iter()
|
||||
.filter(|l| l.starts_with(' '))
|
||||
.map(|l| &l[1..])
|
||||
.collect();
|
||||
if let Some(pos) = find_subsequence(file_lines, &ctx_only) {
|
||||
return Some((pos + 1, old_count, new_count));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Find contiguous `view` lines in `file_lines`. Returns 0-based start index.
|
||||
fn find_contiguous(file_lines: &[String], view: &[&str]) -> Option<usize> {
|
||||
if view.is_empty() || view.len() > file_lines.len() {
|
||||
return None;
|
||||
}
|
||||
let first = view[0];
|
||||
for i in 0..=(file_lines.len() - view.len()) {
|
||||
if file_lines[i].as_str() != first {
|
||||
continue;
|
||||
}
|
||||
if view.iter().enumerate().all(|(j, v)| file_lines[i + j].as_str() == *v) {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find `needles` as an ordered subsequence in `file_lines` within a small window.
|
||||
/// Returns 0-based index of the first needle's position.
|
||||
fn find_subsequence(file_lines: &[String], needles: &[&str]) -> Option<usize> {
|
||||
if needles.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let first = needles[0];
|
||||
for start in 0..file_lines.len() {
|
||||
if file_lines[start].as_str() != first {
|
||||
continue;
|
||||
}
|
||||
let mut cursor = start + 1;
|
||||
let mut all_found = true;
|
||||
for &needle in &needles[1..] {
|
||||
// Allow up to 10 lines gap between consecutive context lines
|
||||
let limit = std::cmp::min(cursor + 10, file_lines.len());
|
||||
match file_lines[cursor..limit]
|
||||
.iter()
|
||||
.position(|fl| fl.as_str() == needle)
|
||||
{
|
||||
Some(offset) => cursor = cursor + offset + 1,
|
||||
None => {
|
||||
all_found = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if all_found {
|
||||
return Some(start);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the last path component as the folder name.
|
||||
pub fn folder_name_from_path(path: &str) -> String {
|
||||
path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
|
||||
|
||||
@@ -652,6 +652,8 @@ impl OpenClawParser {
|
||||
let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p));
|
||||
let mut turns = group_into_turns(messages);
|
||||
super::relocate_orphaned_tool_results(&mut turns);
|
||||
super::structurize_read_tool_output(&mut turns);
|
||||
super::resolve_patch_line_numbers(&mut turns, cwd.as_deref());
|
||||
|
||||
let context_window_used_tokens = latest_turn_total_usage_tokens(&turns);
|
||||
let context_window_max_tokens = session_meta
|
||||
@@ -983,9 +985,15 @@ fn extract_assistant_content(value: &serde_json::Value) -> Vec<ContentBlock> {
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let is_edit_tool = matches!(
|
||||
tool_name.to_lowercase().as_str(),
|
||||
"edit" | "write" | "apply_patch" | "patch" | "applypatch"
|
||||
| "edit_file" | "editfile"
|
||||
);
|
||||
let max_len = if is_edit_tool { 50000 } else { 500 };
|
||||
let input_preview = item.get("arguments").map(|a| {
|
||||
let s = a.to_string();
|
||||
truncate_str(&s, 500)
|
||||
truncate_str(&s, max_len)
|
||||
});
|
||||
blocks.push(ContentBlock::ToolUse {
|
||||
tool_use_id,
|
||||
|
||||
@@ -189,6 +189,8 @@ impl OpenCodeParser {
|
||||
let messages = self.load_sqlite_messages(&conn, conversation_id).await?;
|
||||
let mut turns = group_into_turns(messages);
|
||||
super::relocate_orphaned_tool_results(&mut turns);
|
||||
super::structurize_read_tool_output(&mut turns);
|
||||
super::resolve_patch_line_numbers(&mut turns, summary.folder_path.as_deref());
|
||||
let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns);
|
||||
let context_window_max_tokens =
|
||||
super::infer_context_window_max_tokens(summary.model.as_deref());
|
||||
|
||||
Reference in New Issue
Block a user