返回 CodeWhale
shell_output.rs
根目录 / crates / tui / src / tools / shell_output.rs
1 //! Output truncation and summarization helpers for shell tools.
2
3 /// Maximum output size before truncation (30KB like Claude Code).
4 const MAX_OUTPUT_SIZE: usize = 30_000;
5 /// Head bytes preserved for large shell/test output. Qwen-style: head is
6 /// `threshold / 5` so the bulk of the budget stays on the tail (compiler
7 /// summaries, test failures) without a second command.
8 const TRUNCATED_HEAD_BYTES: usize = MAX_OUTPUT_SIZE / 5;
9 const TRUNCATED_TAIL_BYTES: usize = MAX_OUTPUT_SIZE - TRUNCATED_HEAD_BYTES;
10 /// Limits for summary strings in tool metadata.
11 const SUMMARY_MAX_LINES: usize = 3;
12 const SUMMARY_MAX_CHARS: usize = 240;
13 /// Maximum number of preserved high-signal lines extracted from the tail
14 /// when output is truncated (#242).
15 const MAX_PRESERVED_SUMMARY_LINES: usize = 80;
16 /// Byte ceiling for the whole preserved-summary block, and the character
17 /// ceiling applied to each line before it is admitted.
18 ///
19 /// The line count alone does not bound the block: a single rustc `error:` or
20 /// `note:` line carrying a long inferred type, a minified bundler frame, or a
21 /// `--verbose` link command is routinely hundreds of kilobytes, and eighty of
22 /// them are unbounded in every way that matters to a context budget. These two
23 /// limits are what make the block small next to [`MAX_OUTPUT_SIZE`].
24 const MAX_PRESERVED_SUMMARY_BYTES: usize = 4 * 1024;
25 const MAX_PRESERVED_SUMMARY_LINE_CHARS: usize = 400;
26
27 #[derive(Debug, Clone, Copy, Default)]
28 pub(crate) struct TruncationMeta {
29 pub(crate) original_len: usize,
30 pub(crate) omitted: usize,
31 pub(crate) truncated: bool,
32 }
33
34 pub(crate) fn truncate_with_meta(output: &str) -> (String, TruncationMeta) {
35 let original_len = output.len();
36 if original_len <= MAX_OUTPUT_SIZE {
37 return (
38 output.to_string(),
39 TruncationMeta {
40 original_len,
41 omitted: 0,
42 truncated: false,
43 },
44 );
45 }
46
47 let head_end = char_boundary_at_or_before(output, TRUNCATED_HEAD_BYTES);
48 let tail_start =
49 char_boundary_at_or_after(output, original_len.saturating_sub(TRUNCATED_TAIL_BYTES));
50 let head = &output[..head_end];
51 let omitted_middle = &output[head_end..tail_start];
52 let tail = &output[tail_start..];
53 let omitted = omitted_middle.len();
54 let note = format!(
55 "...\n\n[Output truncated: showing first {head_bytes} bytes and last {tail_bytes} bytes. {omitted} bytes omitted.]",
56 head_bytes = head.len(),
57 tail_bytes = tail.len(),
58 );
59
60 // Preserve high-signal summary lines from the omitted middle (cargo test
61 // results, rustc errors, panics, completion markers). The raw tail is
62 // already included below; these snippets keep earlier failures visible
63 // without re-running `cargo test | tail` repeatedly (#242/#1450).
64 let mut combined = format!("{head}{note}");
65 let preserved = collect_summary_lines(omitted_middle);
66 if !preserved.is_empty() {
67 combined.push_str("\n\n[Preserved summary lines from omitted middle]\n");
68 combined.push_str(&preserved.join("\n"));
69 }
70 combined.push_str("\n\n[Output tail]\n");
71 combined.push_str(tail);
72
73 (
74 combined,
75 TruncationMeta {
76 original_len,
77 omitted,
78 truncated: true,
79 },
80 )
81 }
82
83 /// Extract high-signal summary lines from a chunk of output that would
84 /// otherwise be discarded by truncation. Recognises Cargo/rustc output,
85 /// generic test framework summaries, panic markers, exit-status lines,
86 /// and `Finished`/`running ...` markers. Returns at most
87 /// `MAX_PRESERVED_SUMMARY_LINES` lines, oldest-first within each match
88 /// class so the most actionable signal is at the end.
89 ///
90 /// Each line is clipped to `MAX_PRESERVED_SUMMARY_LINE_CHARS` and the block
91 /// stops at `MAX_PRESERVED_SUMMARY_BYTES`. Both bounds are load-bearing: the
92 /// line count alone let one very wide `error:` line put the entire omitted
93 /// middle back into a result the caller had just bounded to
94 /// `MAX_OUTPUT_SIZE`.
95 pub(crate) fn collect_summary_lines(text: &str) -> Vec<String> {
96 let mut preserved: Vec<String> = Vec::new();
97 let mut remaining = MAX_PRESERVED_SUMMARY_BYTES;
98 for line in text.lines() {
99 if preserved.len() >= MAX_PRESERVED_SUMMARY_LINES {
100 break;
101 }
102 if !is_summary_line(line) {
103 continue;
104 }
105 let clipped = truncate_chars(line, MAX_PRESERVED_SUMMARY_LINE_CHARS);
106 // `+ 1` for the newline `truncate_with_meta` joins these lines with.
107 let cost = clipped.len().saturating_add(1);
108 if cost > remaining {
109 break;
110 }
111 remaining -= cost;
112 preserved.push(clipped);
113 }
114 preserved
115 }
116
117 /// Heuristics for "this line is worth preserving even when most of the
118 /// output is dropped." Tuned for Cargo/rustc and generic test runner
119 /// vocabulary. Intentionally conservative: false positives only cost a
120 /// handful of bytes; false negatives force the agent to re-run gates.
121 fn is_summary_line(line: &str) -> bool {
122 let trimmed = line.trim_start();
123 if trimmed.is_empty() {
124 return false;
125 }
126 // Cargo / rustc canonical markers. Note `trim_start` already stripped
127 // any leading whitespace, so match the bare word — the indentation
128 // Cargo prints (e.g. " Finished") would never reach this point.
129 if trimmed.starts_with("test result:")
130 || trimmed.starts_with("failures:")
131 || trimmed.starts_with("FAILED")
132 || trimmed.starts_with("error[")
133 || trimmed.starts_with("error:")
134 || trimmed.starts_with("warning:")
135 || trimmed.starts_with("panicked at")
136 || trimmed.starts_with("note:")
137 || trimmed.starts_with("help:")
138 || trimmed.starts_with("Finished")
139 || trimmed.starts_with("Compiling")
140 || trimmed.starts_with("Building")
141 || trimmed.starts_with("Running")
142 || trimmed.starts_with("running ")
143 || trimmed.starts_with("Doc-tests")
144 || trimmed.starts_with("---- ")
145 {
146 return true;
147 }
148 // Generic test runner vocabulary.
149 if trimmed.contains("PASS") || trimmed.contains("FAIL") || trimmed.contains("ASSERT") {
150 return true;
151 }
152 // Process-level signal lines.
153 if trimmed.starts_with("Killed")
154 || trimmed.starts_with("Aborted")
155 || trimmed.starts_with("Segmentation fault")
156 || trimmed.starts_with("Error:")
157 || trimmed.starts_with("exit status")
158 || trimmed.starts_with("exit code")
159 {
160 return true;
161 }
162 // `test some::name ... ok|FAILED|ignored` is the per-test result line in
163 // libtest. Cheap to match and useful for pinpointing the failing case.
164 if trimmed.starts_with("test ") && (trimmed.ends_with("FAILED") || trimmed.ends_with("ignored"))
165 {
166 return true;
167 }
168 false
169 }
170
171 fn char_boundary_at_or_before(text: &str, max_bytes: usize) -> usize {
172 if max_bytes >= text.len() {
173 return text.len();
174 }
175
176 let mut last_end = 0usize;
177 for (idx, ch) in text.char_indices() {
178 let end = idx.saturating_add(ch.len_utf8());
179 if end > max_bytes {
180 break;
181 }
182 last_end = end;
183 }
184
185 last_end.min(text.len())
186 }
187
188 fn char_boundary_at_or_after(text: &str, min_bytes: usize) -> usize {
189 if min_bytes >= text.len() {
190 return text.len();
191 }
192 if text.is_char_boundary(min_bytes) {
193 return min_bytes;
194 }
195 text.char_indices()
196 .map(|(idx, _)| idx)
197 .find(|&idx| idx > min_bytes)
198 .unwrap_or(text.len())
199 }
200
201 fn strip_truncation_note(text: &str) -> &str {
202 text.split_once("\n\n[Output truncated")
203 .map_or(text, |(prefix, _)| prefix)
204 }
205
206 fn truncate_chars(text: &str, max_chars: usize) -> String {
207 if text.chars().count() <= max_chars {
208 return text.to_string();
209 }
210
211 let mut end = text.len();
212 for (count, (idx, _)) in text.char_indices().enumerate() {
213 if count == max_chars {
214 end = idx;
215 break;
216 }
217 }
218
219 format!("{}...", &text[..end])
220 }
221
222 pub(crate) fn summarize_output(text: &str) -> String {
223 let stripped = strip_truncation_note(text);
224 let summary = stripped
225 .lines()
226 .take(SUMMARY_MAX_LINES)
227 .collect::<Vec<_>>()
228 .join("\n")
229 .trim()
230 .to_string();
231
232 if summary.is_empty() {
233 String::new()
234 } else {
235 truncate_chars(&summary, SUMMARY_MAX_CHARS)
236 }
237 }
238
239 #[cfg(test)]
240 mod tests {
241 use super::*;
242
243 #[test]
244 fn truncation_preserves_cargo_test_summary_lines_from_tail() {
245 let mut head = String::with_capacity(MAX_OUTPUT_SIZE + 4_000);
246 head.push_str("running 5 tests\n");
247 for i in 0..3_000 {
248 head.push_str(&format!("test test::case_{i} ... ok\n"));
249 }
250 // Pad to force tail truncation
251 while head.len() < MAX_OUTPUT_SIZE {
252 head.push_str("...padding line below threshold...\n");
253 }
254 head.push_str("\ntest result: ok. 1687 passed; 0 failed; 2 ignored\n");
255 head.push_str(" Finished `dev` profile target(s) in 4.87s\n");
256
257 let (truncated, meta) = truncate_with_meta(&head);
258 assert!(meta.truncated, "expected truncation");
259 assert!(
260 truncated.contains("test result: ok. 1687 passed"),
261 "summary line must be preserved\nGot: {}",
262 &truncated[truncated.len().saturating_sub(400)..]
263 );
264 assert!(
265 truncated.contains("Finished"),
266 "Finished marker must be preserved"
267 );
268 }
269
270 #[test]
271 fn truncation_preserves_failure_lines_from_tail() {
272 let mut head = String::with_capacity(MAX_OUTPUT_SIZE + 1_000);
273 for _ in 0..MAX_OUTPUT_SIZE {
274 head.push('a');
275 }
276 head.push_str("\nfailures:\n test::flaky_thing FAILED\n");
277 head.push_str("test result: FAILED. 0 passed; 1 failed\n");
278
279 let (truncated, _meta) = truncate_with_meta(&head);
280 assert!(truncated.contains("failures:"), "must preserve failures:");
281 assert!(truncated.contains("FAILED"), "must preserve FAILED");
282 }
283
284 #[test]
285 fn truncation_includes_raw_tail_for_shell_output() {
286 let mut output = String::new();
287 output.push_str("head-marker\n");
288 output.push_str(&"middle noise\n".repeat(3_000));
289 output.push_str("tail-marker: final compiler error\n");
290
291 let (truncated, meta) = truncate_with_meta(&output);
292
293 assert!(meta.truncated, "expected truncation");
294 assert!(truncated.contains("head-marker"));
295 assert!(
296 truncated.contains("[Output tail]"),
297 "tail section should be explicit: {truncated}"
298 );
299 assert!(
300 truncated.contains("tail-marker: final compiler error"),
301 "raw tail must remain visible"
302 );
303 }
304
305 #[test]
306 fn preserved_summary_lines_are_bounded_in_bytes_not_only_in_count() {
307 // One rustc `error:` line can be megabytes wide (a long inferred type,
308 // a minified bundler frame, a `--verbose` link line). Landing in the
309 // omitted middle, it was re-inlined verbatim by the preserved-summary
310 // block, so the "30KB" truncation returned a 400KB tool result.
311 let mut output = String::from("head-marker\n");
312 output.push_str(&"noise line\n".repeat(1_000));
313 output.push_str(&format!("error: {}\n", "T".repeat(400_000)));
314 output.push_str(&"noise line\n".repeat(4_000));
315 output.push_str("tail-marker\n");
316
317 let (truncated, meta) = truncate_with_meta(&output);
318 assert!(meta.truncated, "expected truncation");
319 assert!(
320 truncated.contains("error: TTT"),
321 "the high-signal line must still be preserved"
322 );
323 assert!(
324 truncated.len() <= MAX_OUTPUT_SIZE + 2 * MAX_PRESERVED_SUMMARY_BYTES,
325 "preserved summary blew the output budget: {} bytes",
326 truncated.len()
327 );
328 }
329
330 #[test]
331 fn collect_summary_lines_skips_noise() {
332 let body = "\nblah blah\nrandom line\nokay\n\n";
333 assert!(collect_summary_lines(body).is_empty());
334 }
335
336 #[test]
337 fn collect_summary_lines_picks_rustc_errors() {
338 let body = "\
339 some preamble
340 error[E0277]: the trait `Foo` is not implemented for `Bar`
341 --> src/lib.rs:42:9
342 warning: unused variable
343 note: see help
344 ";
345 let preserved = collect_summary_lines(body);
346 assert!(preserved.iter().any(|line| line.contains("error[E0277]")));
347 assert!(preserved.iter().any(|line| line.contains("warning:")));
348 }
349 }
350
350 lines RUST