修复并行命令的执行结果没有对应到命令块
This commit is contained in:
@@ -558,7 +558,8 @@ impl ClaudeParser {
|
||||
let folder_path = cwd.clone();
|
||||
let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p));
|
||||
|
||||
let turns = group_into_turns(messages);
|
||||
let mut turns = group_into_turns(messages);
|
||||
super::relocate_orphaned_tool_results(&mut turns);
|
||||
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());
|
||||
|
||||
@@ -771,7 +771,8 @@ impl CodexParser {
|
||||
let folder_path = cwd.clone();
|
||||
let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p));
|
||||
|
||||
let turns = group_into_turns(messages);
|
||||
let mut turns = group_into_turns(messages);
|
||||
super::relocate_orphaned_tool_results(&mut turns);
|
||||
let mut session_stats = super::compute_session_stats(&turns);
|
||||
session_stats =
|
||||
merge_codex_total_usage_stats(session_stats, latest_total_usage, latest_total_tokens);
|
||||
|
||||
@@ -554,7 +554,8 @@ impl GeminiParser {
|
||||
}
|
||||
}
|
||||
|
||||
let turns = group_into_turns(messages);
|
||||
let mut turns = group_into_turns(messages);
|
||||
super::relocate_orphaned_tool_results(&mut turns);
|
||||
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);
|
||||
|
||||
@@ -4,12 +4,13 @@ pub mod gemini;
|
||||
pub mod openclaw;
|
||||
pub mod opencode;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::models::{
|
||||
ConversationDetail, ConversationSummary, MessageTurn, SessionStats, TurnUsage,
|
||||
ContentBlock, ConversationDetail, ConversationSummary, MessageTurn, SessionStats, TurnUsage,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -206,6 +207,91 @@ pub fn merge_context_window_stats(
|
||||
}
|
||||
}
|
||||
|
||||
/// Relocate orphaned tool_result blocks to the turn that contains their matching tool_use.
|
||||
///
|
||||
/// After `group_into_turns` splits assistant rounds, async tool execution can cause
|
||||
/// a tool_result to land in a later turn than its corresponding tool_use.
|
||||
/// This post-processing step moves such orphaned results back.
|
||||
pub fn relocate_orphaned_tool_results(turns: &mut Vec<MessageTurn>) {
|
||||
// Build map: tool_use_id → turn index
|
||||
let mut tool_use_turn: HashMap<String, usize> = HashMap::new();
|
||||
for (idx, turn) in turns.iter().enumerate() {
|
||||
for block in &turn.blocks {
|
||||
if let ContentBlock::ToolUse {
|
||||
tool_use_id: Some(ref id),
|
||||
..
|
||||
} = block
|
||||
{
|
||||
tool_use_turn.insert(id.clone(), idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tool_use_turn.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect (source_turn, target_turn, block) for orphaned results
|
||||
let mut relocations: Vec<(usize, usize, ContentBlock)> = Vec::new();
|
||||
for (turn_idx, turn) in turns.iter().enumerate() {
|
||||
for block in &turn.blocks {
|
||||
if let ContentBlock::ToolResult {
|
||||
tool_use_id: Some(ref id),
|
||||
..
|
||||
} = block
|
||||
{
|
||||
if let Some(&target) = tool_use_turn.get(id) {
|
||||
if target != turn_idx {
|
||||
relocations.push((turn_idx, target, block.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if relocations.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build set of (turn_idx, tool_use_id) to remove
|
||||
let remove_set: HashMap<usize, Vec<String>> = {
|
||||
let mut map: HashMap<usize, Vec<String>> = HashMap::new();
|
||||
for (from, _, block) in &relocations {
|
||||
if let ContentBlock::ToolResult {
|
||||
tool_use_id: Some(ref id),
|
||||
..
|
||||
} = block
|
||||
{
|
||||
map.entry(*from).or_default().push(id.clone());
|
||||
}
|
||||
}
|
||||
map
|
||||
};
|
||||
|
||||
// Remove from source turns
|
||||
for (&turn_idx, ids) in &remove_set {
|
||||
turns[turn_idx].blocks.retain(|block| {
|
||||
if let ContentBlock::ToolResult {
|
||||
tool_use_id: Some(ref id),
|
||||
..
|
||||
} = block
|
||||
{
|
||||
!ids.contains(id)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Append to target turns
|
||||
for (_, target, block) in relocations {
|
||||
turns[target].blocks.push(block);
|
||||
}
|
||||
|
||||
// Remove turns that became empty after relocation
|
||||
turns.retain(|turn| !turn.blocks.is_empty());
|
||||
}
|
||||
|
||||
/// 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()
|
||||
|
||||
@@ -650,7 +650,8 @@ impl OpenClawParser {
|
||||
|
||||
let folder_path = cwd.clone();
|
||||
let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p));
|
||||
let turns = group_into_turns(messages);
|
||||
let mut turns = group_into_turns(messages);
|
||||
super::relocate_orphaned_tool_results(&mut turns);
|
||||
|
||||
let context_window_used_tokens = latest_turn_total_usage_tokens(&turns);
|
||||
let context_window_max_tokens = session_meta
|
||||
|
||||
@@ -187,7 +187,8 @@ impl OpenCodeParser {
|
||||
.ok_or_else(|| ParseError::ConversationNotFound(conversation_id.to_string()))?;
|
||||
|
||||
let messages = self.load_sqlite_messages(&conn, conversation_id).await?;
|
||||
let turns = group_into_turns(messages);
|
||||
let mut turns = group_into_turns(messages);
|
||||
super::relocate_orphaned_tool_results(&mut turns);
|
||||
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());
|
||||
|
||||
@@ -2012,36 +2012,84 @@ function stripMarkdownCodeFence(text: string): string {
|
||||
return result
|
||||
}
|
||||
|
||||
function stripCliExecutionEnvelope(text: string): string {
|
||||
/** Regex matching metadata lines in CLI execution output envelopes. */
|
||||
const CLI_META_LINE_RE =
|
||||
/^(exit code\s*[:=]|wall time\s*[:=]|chunk id\s*[:=]|original token count\s*[:=]|total output lines\s*[:=]|process exited with code\s)/i
|
||||
|
||||
/**
|
||||
* Parse a CLI execution envelope, stripping all metadata and the "Output:"
|
||||
* separator, returning only the actual command output and the wall time.
|
||||
*
|
||||
* Handles formats like:
|
||||
* Chunk ID: 065b2b
|
||||
* Wall time: 0.05s
|
||||
* Process exited with code 0
|
||||
* Original token count: 27006
|
||||
* Output:
|
||||
* Total output lines: 1134
|
||||
* <actual output here>
|
||||
*/
|
||||
function parseCliExecutionEnvelope(text: string): {
|
||||
output: string
|
||||
wallTime: string | null
|
||||
} {
|
||||
const lines = text.split("\n")
|
||||
let wallTime: string | null = null
|
||||
|
||||
// Look for "Output:" separator and extract wall time from header
|
||||
let outputSepIndex = -1
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const trimmed = lines[i].trim()
|
||||
const wtMatch = trimmed.match(/^wall time\s*:\s*(.+)/i)
|
||||
if (wtMatch) wallTime = wtMatch[1].trim()
|
||||
if (/^output:\s*$/i.test(trimmed)) {
|
||||
outputSepIndex = i
|
||||
break
|
||||
}
|
||||
// Stop scanning if we hit a non-metadata, non-blank line (actual content)
|
||||
if (!CLI_META_LINE_RE.test(trimmed) && trimmed.length > 0) break
|
||||
}
|
||||
|
||||
// If "Output:" separator found, skip everything before it plus any
|
||||
// remaining metadata/blank lines after it
|
||||
if (outputSepIndex >= 0) {
|
||||
let start = outputSepIndex + 1
|
||||
while (start < lines.length) {
|
||||
const trimmed = lines[start].trim()
|
||||
if (CLI_META_LINE_RE.test(trimmed) || trimmed.length === 0) {
|
||||
start++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return { output: lines.slice(start).join("\n"), wallTime }
|
||||
}
|
||||
|
||||
// No "Output:" separator — strip leading metadata lines
|
||||
let index = 0
|
||||
let sawMeta = false
|
||||
|
||||
while (index < lines.length) {
|
||||
const trimmed = lines[index].trim()
|
||||
if (/^exit code:\s*/i.test(trimmed) || /^wall time:\s*/i.test(trimmed)) {
|
||||
if (CLI_META_LINE_RE.test(trimmed)) {
|
||||
sawMeta = true
|
||||
index += 1
|
||||
if (!wallTime) {
|
||||
const wtMatch = trimmed.match(/^wall time\s*:\s*(.+)/i)
|
||||
if (wtMatch) wallTime = wtMatch[1].trim()
|
||||
}
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (sawMeta && trimmed.length === 0) {
|
||||
index += 1
|
||||
index++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (!sawMeta) return text
|
||||
if (!sawMeta) return { output: text, wallTime: null }
|
||||
|
||||
if (index < lines.length && /^output:\s*$/i.test(lines[index].trim())) {
|
||||
index += 1
|
||||
}
|
||||
|
||||
while (index < lines.length && lines[index].trim().length === 0) {
|
||||
index += 1
|
||||
}
|
||||
|
||||
return lines.slice(index).join("\n")
|
||||
while (index < lines.length && lines[index].trim().length === 0) index++
|
||||
return { output: lines.slice(index).join("\n"), wallTime }
|
||||
}
|
||||
|
||||
// ── Part components ───────────────────────────────────────────────────
|
||||
@@ -2123,26 +2171,51 @@ const ToolCallPart = memo(function ToolCallPart({
|
||||
}
|
||||
return null
|
||||
}, [toolNameLower, part.input, part.output, part.errorText])
|
||||
const wallTime = useMemo(() => {
|
||||
const source = part.output ?? part.errorText
|
||||
if (!source) return null
|
||||
const normalized = commandOutputFromJsonString(source) ?? source
|
||||
const match = normalized.match(/^wall time\s*:\s*(.+)/im)
|
||||
if (!match) return null
|
||||
const raw = match[1].trim()
|
||||
// Parse "0.0519 seconds" → "52ms", "1.234 seconds" → "1.2s"
|
||||
const numMatch = raw.match(/^([\d.]+)\s*s/)
|
||||
if (!numMatch) return raw
|
||||
const sec = parseFloat(numMatch[1])
|
||||
if (Number.isNaN(sec)) return raw
|
||||
if (sec < 0.001) return "<1ms"
|
||||
if (sec < 1) return `${Math.round(sec * 1000)}ms`
|
||||
if (sec < 60) return `${sec.toFixed(1)}s`
|
||||
return `${(sec / 60).toFixed(1)}m`
|
||||
}, [part.output, part.errorText])
|
||||
const titleSuffix = useMemo(() => {
|
||||
if (!lineChangeStats) return null
|
||||
const hasStats =
|
||||
lineChangeStats &&
|
||||
(lineChangeStats.additions > 0 || lineChangeStats.deletions > 0)
|
||||
if (!hasStats && !wallTime) return null
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium">
|
||||
{lineChangeStats.additions > 0 && (
|
||||
{hasStats && lineChangeStats.additions > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 text-green-600 dark:text-green-400">
|
||||
<PlusIcon className="size-3" />
|
||||
{lineChangeStats.additions}
|
||||
</span>
|
||||
)}
|
||||
{lineChangeStats.deletions > 0 && (
|
||||
{hasStats && lineChangeStats.deletions > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 text-red-600 dark:text-red-400">
|
||||
<MinusIcon className="size-3" />
|
||||
{lineChangeStats.deletions}
|
||||
</span>
|
||||
)}
|
||||
{wallTime && (
|
||||
<span className="text-muted-foreground/60 font-normal">
|
||||
{wallTime}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}, [lineChangeStats])
|
||||
}, [lineChangeStats, wallTime])
|
||||
|
||||
const icon = useMemo(
|
||||
() => getToolIcon(normalizedToolName, part.input),
|
||||
@@ -2157,9 +2230,7 @@ const ToolCallPart = memo(function ToolCallPart({
|
||||
)
|
||||
}, [isCommandTool, part.input, part.output, part.errorText])
|
||||
const commandOutput = useMemo(() => {
|
||||
if (!isCommandLikeTool) {
|
||||
return null
|
||||
}
|
||||
if (!isCommandLikeTool) return null
|
||||
const source =
|
||||
typeof part.output === "string"
|
||||
? part.output
|
||||
@@ -2168,7 +2239,8 @@ const ToolCallPart = memo(function ToolCallPart({
|
||||
: null
|
||||
if (!source) return null
|
||||
const normalized = commandOutputFromJsonString(source) ?? source
|
||||
return stripMarkdownCodeFence(stripCliExecutionEnvelope(normalized))
|
||||
const envelope = parseCliExecutionEnvelope(normalized)
|
||||
return stripMarkdownCodeFence(envelope.output)
|
||||
}, [isCommandLikeTool, part.output, part.errorText])
|
||||
const hasLiveOutput =
|
||||
isRunning && isCommandTool && typeof commandOutput === "string"
|
||||
|
||||
@@ -231,10 +231,7 @@ export function MessageListView({
|
||||
// isRoleTransition: role differs from previous turn item
|
||||
if (idx > 0) {
|
||||
const prev = items[idx - 1]
|
||||
if (
|
||||
prev.kind === "turn" &&
|
||||
prev.group.role !== item.group.role
|
||||
) {
|
||||
if (prev.kind === "turn" && prev.group.role !== item.group.role) {
|
||||
item.isRoleTransition = true
|
||||
}
|
||||
}
|
||||
@@ -242,11 +239,7 @@ export function MessageListView({
|
||||
// showStats: only on the last assistant turn before a non-assistant or end
|
||||
if (item.group.role === "assistant") {
|
||||
const next = items[idx + 1]
|
||||
if (
|
||||
!next ||
|
||||
next.kind !== "turn" ||
|
||||
next.group.role !== "assistant"
|
||||
) {
|
||||
if (!next || next.kind !== "turn" || next.group.role !== "assistant") {
|
||||
item.showStats = true
|
||||
}
|
||||
}
|
||||
@@ -272,29 +265,26 @@ export function MessageListView({
|
||||
[historicalPlanEntries]
|
||||
)
|
||||
|
||||
const renderThreadItem = useCallback(
|
||||
(item: ThreadRenderItem) => {
|
||||
switch (item.kind) {
|
||||
case "turn": {
|
||||
const pt = item.isRoleTransition ? 16 : 0
|
||||
return (
|
||||
<div style={pt > 0 ? { paddingTop: pt } : undefined}>
|
||||
<HistoricalMessageGroup
|
||||
group={item.group}
|
||||
dimmed={item.phase === "optimistic"}
|
||||
showStats={item.showStats}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case "typing":
|
||||
return <PendingTypingIndicator />
|
||||
default:
|
||||
return null
|
||||
const renderThreadItem = useCallback((item: ThreadRenderItem) => {
|
||||
switch (item.kind) {
|
||||
case "turn": {
|
||||
const pt = item.isRoleTransition ? 16 : 0
|
||||
return (
|
||||
<div style={pt > 0 ? { paddingTop: pt } : undefined}>
|
||||
<HistoricalMessageGroup
|
||||
group={item.group}
|
||||
dimmed={item.phase === "optimistic"}
|
||||
showStats={item.showStats}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
case "typing":
|
||||
return <PendingTypingIndicator />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const emptyState = useMemo(
|
||||
() =>
|
||||
|
||||
Reference in New Issue
Block a user