优化编辑工具调用的行号解析
This commit is contained in:
@@ -53,6 +53,46 @@ fn strip_system_tags(text: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a JSONL entry is a system meta message (isMeta: true).
|
/// 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 {
|
fn is_meta_message(value: &serde_json::Value) -> bool {
|
||||||
value
|
value
|
||||||
.get("isMeta")
|
.get("isMeta")
|
||||||
@@ -489,7 +529,7 @@ impl ClaudeParser {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
"user" => {
|
"user" => {
|
||||||
let content = extract_user_content(&value);
|
let mut content = extract_user_content(&value);
|
||||||
|
|
||||||
// Skip user messages that are empty after system tag stripping
|
// Skip user messages that are empty after system tag stripping
|
||||||
if content.is_empty() {
|
if content.is_empty() {
|
||||||
@@ -518,6 +558,31 @@ impl ClaudeParser {
|
|||||||
MessageRole::User
|
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 {
|
messages.push(UnifiedMessage {
|
||||||
id: uuid,
|
id: uuid,
|
||||||
role,
|
role,
|
||||||
@@ -738,6 +803,7 @@ impl ClaudeParser {
|
|||||||
let mut turns = group_into_turns(messages);
|
let mut turns = group_into_turns(messages);
|
||||||
super::relocate_orphaned_tool_results(&mut turns);
|
super::relocate_orphaned_tool_results(&mut turns);
|
||||||
super::structurize_read_tool_output(&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_used_tokens = latest_claude_context_window_used_tokens(&turns);
|
||||||
let context_window_max_tokens =
|
let context_window_max_tokens =
|
||||||
claude_context_window_max_tokens_for_model(model.as_deref());
|
claude_context_window_max_tokens_for_model(model.as_deref());
|
||||||
|
|||||||
@@ -774,6 +774,7 @@ impl CodexParser {
|
|||||||
let mut turns = group_into_turns(messages);
|
let mut turns = group_into_turns(messages);
|
||||||
super::relocate_orphaned_tool_results(&mut turns);
|
super::relocate_orphaned_tool_results(&mut turns);
|
||||||
super::structurize_read_tool_output(&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);
|
let mut session_stats = super::compute_session_stats(&turns);
|
||||||
session_stats =
|
session_stats =
|
||||||
merge_codex_total_usage_stats(session_stats, latest_total_usage, latest_total_tokens);
|
merge_codex_total_usage_stats(session_stats, latest_total_usage, latest_total_tokens);
|
||||||
|
|||||||
@@ -557,6 +557,7 @@ impl GeminiParser {
|
|||||||
let mut turns = group_into_turns(messages);
|
let mut turns = group_into_turns(messages);
|
||||||
super::relocate_orphaned_tool_results(&mut turns);
|
super::relocate_orphaned_tool_results(&mut turns);
|
||||||
super::structurize_read_tool_output(&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.message_count = turns.len() as u32;
|
||||||
summary.id = conversation_id.to_string();
|
summary.id = conversation_id.to_string();
|
||||||
let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns);
|
let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns);
|
||||||
|
|||||||
@@ -403,6 +403,206 @@ fn strip_numbered_lines(text: &str) -> Option<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 @@`.
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
fn find_hunk_position(
|
||||||
|
file_lines: &[String],
|
||||||
|
hunk_lines: &[&str],
|
||||||
|
) -> Option<(usize, usize, usize)> {
|
||||||
|
// Extract context lines (space-prefixed) with their positions in the hunk
|
||||||
|
let context_entries: Vec<(usize, &str)> = hunk_lines
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, l)| l.starts_with(' '))
|
||||||
|
.map(|(i, l)| (i, &l[1..])) // strip leading space
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if context_entries.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first context line to find candidate positions
|
||||||
|
let (first_ctx_idx, first_ctx_text) = context_entries[0];
|
||||||
|
|
||||||
|
for (file_idx, file_line) in file_lines.iter().enumerate() {
|
||||||
|
if file_line.as_str() != first_ctx_text {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all context lines match from this position
|
||||||
|
let hunk_start_in_file = file_idx as isize - first_ctx_idx as isize;
|
||||||
|
if hunk_start_in_file < 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let all_match = context_entries.iter().all(|(ctx_idx, ctx_text)| {
|
||||||
|
// Map hunk position to file position: only count context and deleted lines
|
||||||
|
let file_offset = hunk_lines[..*ctx_idx]
|
||||||
|
.iter()
|
||||||
|
.filter(|hl| hl.starts_with(' ') || hl.starts_with('-'))
|
||||||
|
.count();
|
||||||
|
let target = hunk_start_in_file as usize + file_offset;
|
||||||
|
target < file_lines.len() && file_lines[target].as_str() == *ctx_text
|
||||||
|
});
|
||||||
|
|
||||||
|
if all_match {
|
||||||
|
// Calculate old_count (context + deleted) and new_count (context + added)
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// file_start is the first line of the old file covered by this hunk
|
||||||
|
// We need to find where context/deleted lines start in the file
|
||||||
|
let file_start_offset = hunk_lines[..first_ctx_idx]
|
||||||
|
.iter()
|
||||||
|
.filter(|hl| hl.starts_with(' ') || hl.starts_with('-'))
|
||||||
|
.count();
|
||||||
|
let start_line = (hunk_start_in_file as usize - file_start_offset) + 1; // 1-based
|
||||||
|
return Some((start_line, old_count, new_count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract the last path component as the folder name.
|
/// Extract the last path component as the folder name.
|
||||||
pub fn folder_name_from_path(path: &str) -> String {
|
pub fn folder_name_from_path(path: &str) -> String {
|
||||||
path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
|
path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
|
||||||
|
|||||||
@@ -653,6 +653,7 @@ impl OpenClawParser {
|
|||||||
let mut turns = group_into_turns(messages);
|
let mut turns = group_into_turns(messages);
|
||||||
super::relocate_orphaned_tool_results(&mut turns);
|
super::relocate_orphaned_tool_results(&mut turns);
|
||||||
super::structurize_read_tool_output(&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_used_tokens = latest_turn_total_usage_tokens(&turns);
|
||||||
let context_window_max_tokens = session_meta
|
let context_window_max_tokens = session_meta
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ impl OpenCodeParser {
|
|||||||
let mut turns = group_into_turns(messages);
|
let mut turns = group_into_turns(messages);
|
||||||
super::relocate_orphaned_tool_results(&mut turns);
|
super::relocate_orphaned_tool_results(&mut turns);
|
||||||
super::structurize_read_tool_output(&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_used_tokens = super::latest_turn_total_usage_tokens(&turns);
|
||||||
let context_window_max_tokens =
|
let context_window_max_tokens =
|
||||||
super::infer_context_window_max_tokens(summary.model.as_deref());
|
super::infer_context_window_max_tokens(summary.model.as_deref());
|
||||||
|
|||||||
@@ -1755,6 +1755,16 @@ function StructuredToolInput({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Prefer tool output if it contains a structured diff with real line numbers
|
||||||
|
// (injected by backend from toolUseResult.structuredPatch)
|
||||||
|
if (output && typeof output === "string" && /^@@ /m.test(output)) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{truncationBanner}
|
||||||
|
<UnifiedDiffPreview diffText={output} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (isCanonicalEditPayload(parsed)) {
|
if (isCanonicalEditPayload(parsed)) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { MessageTurn } from "./types"
|
import type { ContentBlock, MessageTurn } from "./types"
|
||||||
import { normalizeToolName } from "./tool-call-normalization"
|
import { normalizeToolName } from "./tool-call-normalization"
|
||||||
import { estimateChangedLineStats } from "./line-change-stats"
|
import { estimateChangedLineStats } from "./line-change-stats"
|
||||||
import { generateUnifiedDiff } from "./unified-diff-generator"
|
import { generateUnifiedDiff } from "./unified-diff-generator"
|
||||||
@@ -757,10 +757,12 @@ export function extractSessionFilesGrouped(
|
|||||||
block.input_preview,
|
block.input_preview,
|
||||||
normalizedPath
|
normalizedPath
|
||||||
)
|
)
|
||||||
|
const toolOutput = findToolResultOutput(turn.blocks, block.tool_use_id)
|
||||||
const diffChunk = buildDiffChunk(
|
const diffChunk = buildDiffChunk(
|
||||||
normalized,
|
normalized,
|
||||||
block.input_preview,
|
block.input_preview,
|
||||||
normalizedPath
|
normalizedPath,
|
||||||
|
toolOutput
|
||||||
)
|
)
|
||||||
|
|
||||||
currentFiles.push({
|
currentFiles.push({
|
||||||
@@ -819,10 +821,12 @@ export function buildSessionFileDiff(
|
|||||||
)
|
)
|
||||||
if (!blockPaths.includes(normalizedTargetPath)) continue
|
if (!blockPaths.includes(normalizedTargetPath)) continue
|
||||||
|
|
||||||
|
const toolOutput = findToolResultOutput(turn.blocks, block.tool_use_id)
|
||||||
const chunk = buildDiffChunk(
|
const chunk = buildDiffChunk(
|
||||||
normalized,
|
normalized,
|
||||||
block.input_preview,
|
block.input_preview,
|
||||||
normalizedTargetPath
|
normalizedTargetPath,
|
||||||
|
toolOutput
|
||||||
)
|
)
|
||||||
if (chunk && chunk.trim().length > 0) chunks.push(chunk.trim())
|
if (chunk && chunk.trim().length > 0) chunks.push(chunk.trim())
|
||||||
}
|
}
|
||||||
@@ -835,10 +839,30 @@ export function buildSessionFileDiff(
|
|||||||
return chunks.join("\n\n")
|
return chunks.join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Find the tool_result output matching a tool_use_id within the same turn. */
|
||||||
|
function findToolResultOutput(
|
||||||
|
blocks: ContentBlock[],
|
||||||
|
toolUseId: string | null
|
||||||
|
): string | null {
|
||||||
|
if (!toolUseId) return null
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (
|
||||||
|
block.type === "tool_result" &&
|
||||||
|
block.tool_use_id === toolUseId &&
|
||||||
|
block.output_preview &&
|
||||||
|
!block.is_error
|
||||||
|
) {
|
||||||
|
return block.output_preview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function buildDiffChunk(
|
function buildDiffChunk(
|
||||||
op: string,
|
op: string,
|
||||||
inputPreview: string | null,
|
inputPreview: string | null,
|
||||||
filePath: string
|
filePath: string,
|
||||||
|
toolOutput?: string | null
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!inputPreview) return null
|
if (!inputPreview) return null
|
||||||
|
|
||||||
@@ -860,6 +884,11 @@ function buildDiffChunk(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer tool output if backend injected a real diff with line numbers
|
||||||
|
if (toolOutput && /^@@ /m.test(toolOutput)) {
|
||||||
|
return toolOutput.trim()
|
||||||
|
}
|
||||||
|
|
||||||
if (!parsed) return null
|
if (!parsed) return null
|
||||||
|
|
||||||
const oldStr =
|
const oldStr =
|
||||||
|
|||||||
Reference in New Issue
Block a user