vtcode_core/utils/
session_transcript_norm.rs1use crate::tools::tool_intent::canonical_command_session_tool_name;
4use rustc_hash::FxHashSet;
5use vtcode_commons::formatting::collapse_whitespace as collapse_whitespace_inner;
6
7fn collapse_whitespace(text: &str) -> String {
8 collapse_whitespace_inner(text)
9}
10
11pub fn should_drop_transcript_line(line: &str) -> bool {
12 let trimmed = line.trim();
13 trimmed.starts_with("Latest tool output:")
14 || trimmed.starts_with("Latest user request:")
15 || trimmed.starts_with("Tool output 1:")
16 || trimmed.starts_with("Structured result with fields:")
17 || trimmed.starts_with("Reuse the latest tool outputs already collected in this turn")
18 || trimmed.starts_with("Interrupt received. Stopping task...")
19}
20
21pub fn normalize_recovery_line(line: &str) -> Option<String> {
22 let trimmed = line.trim();
23 if trimmed.starts_with("[!] Turn balancer:")
24 || trimmed.starts_with("[!] Navigation Loop:")
25 || trimmed.starts_with("[!] Navigation loop:")
26 {
27 return Some("Repeated low-signal tool churn triggered recovery.".to_string());
28 }
29
30 if trimmed
31 .contains("I couldn't produce a final synthesis because the model returned no answer on the recovery pass.")
32 {
33 return Some("Recovery pass failed to produce a final synthesis.".to_string());
34 }
35
36 None
37}
38
39pub fn summarize_tool_block(lines: &[String], start: usize) -> (String, usize) {
40 let header = lines[start].trim().to_string();
41 let mut command_continuations = Vec::new();
42 let mut metadata = Vec::new();
43 let mut metadata_seen = FxHashSet::default();
44 let mut index = start + 1;
45
46 while index < lines.len() {
47 let raw = lines[index].trim_end();
48 let trimmed = raw.trim_start();
49 if trimmed.starts_with("• ") || trimmed.starts_with("[!]") || !is_tool_detail_line(trimmed) {
50 break;
51 }
52
53 if let Some(continuation) = trimmed.strip_prefix("│ ") {
54 let continuation = continuation.trim();
55 if !continuation.is_empty() {
56 command_continuations.push(continuation.to_string());
57 }
58 } else if let Some(extra) = summarize_tool_detail(trimmed)
59 && metadata_seen.insert(extra.clone())
60 {
61 metadata.push(extra);
62 }
63
64 index += 1;
65 }
66
67 let mut summary = header;
68 if !command_continuations.is_empty() {
69 summary.push(' ');
70 summary.push_str(&command_continuations.join(" "));
71 }
72 if !metadata.is_empty() {
73 summary.push_str(" [");
74 summary.push_str(&metadata.join(", "));
75 summary.push(']');
76 }
77
78 (collapse_whitespace(&summary), index)
79}
80
81fn is_tool_detail_line(line: &str) -> bool {
82 line.starts_with("│ ")
83 || line.starts_with("└ ")
84 || line.starts_with("✓ ")
85 || line.starts_with("✗ ")
86 || line.starts_with("… +")
87 || line.starts_with("Large output was spooled")
88 || line == "(no output)"
89}
90
91fn summarize_tool_detail(line: &str) -> Option<String> {
92 let path = line
93 .strip_prefix("└ Path:")
94 .map(str::trim)
95 .filter(|value| !value.is_empty())
96 .map(|value| format!("path {value}"));
97 if path.is_some() {
98 return path;
99 }
100
101 let pattern = line
102 .strip_prefix("└ Pattern:")
103 .map(str::trim)
104 .filter(|value| !value.is_empty())
105 .map(|value| format!("pattern {value}"));
106 if pattern.is_some() {
107 return pattern;
108 }
109
110 let filter = line
111 .strip_prefix("└ Filter:")
112 .map(str::trim)
113 .filter(|value| !value.is_empty())
114 .map(|value| format!("filter {value}"));
115 if filter.is_some() {
116 return filter;
117 }
118
119 let glob = line
120 .strip_prefix("└ Glob:")
121 .map(str::trim)
122 .filter(|value| !value.is_empty())
123 .map(|value| format!("glob {value}"));
124 if glob.is_some() {
125 return glob;
126 }
127
128 if let Some(status) = line.strip_prefix("✗ ") {
129 let status = status.trim();
130 if !status.is_empty() {
131 return Some(status.to_string());
132 }
133 }
134
135 None
136}
137
138pub fn normalized_transcript_key(text: &str) -> String {
139 collapse_whitespace(text).to_ascii_lowercase()
140}
141
142pub fn format_repeated_summary(line: &str, repeats: usize) -> String {
143 if repeats <= 1 {
144 return line.to_string();
145 }
146 format!("{line} (repeated x{repeats})")
147}
148
149pub fn push_clean_transcript_line(target: &mut Vec<String>, line: String) {
150 let trimmed = line.trim();
151 if trimmed.is_empty() {
152 if target.last().is_none_or(|last| !last.is_empty()) {
153 target.push(String::new());
154 }
155 return;
156 }
157
158 if target
159 .last()
160 .is_some_and(|last| normalized_transcript_key(last) == normalized_transcript_key(trimmed))
161 {
162 return;
163 }
164
165 target.push(line);
166}
167
168pub fn normalize_session_tool_name(name: &str) -> String {
169 canonical_command_session_tool_name(name).unwrap_or(name).to_string()
170}
171
172pub fn normalize_distinct_tools_for_summary(distinct_tools: &[String]) -> Vec<String> {
173 let mut normalized = Vec::with_capacity(distinct_tools.len());
174 let mut seen = std::collections::BTreeSet::new();
175
176 for tool in distinct_tools {
177 let mapped = normalize_session_tool_name(tool);
178 if seen.insert(mapped.clone()) {
179 normalized.push(mapped);
180 }
181 }
182
183 normalized
184}