返回 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). Bounded so the preserved summary
15 /// itself can never blow up the context window.
16 const MAX_PRESERVED_SUMMARY_LINES: usize = 80;
17
18 #[derive(Debug, Clone, Copy, Default)]
19 pub(crate) struct TruncationMeta {
20 pub(crate) original_len: usize,
21 pub(crate) omitted: usize,
22 pub(crate) truncated: bool,
23 }
24
25 pub(crate) fn truncate_with_meta(output: &str) -> (String, TruncationMeta) {
26 let original_len = output.len();
27 if original_len <= MAX_OUTPUT_SIZE {
28 return (
29 output.to_string(),
30 TruncationMeta {
31 original_len,
32 omitted: 0,
33 truncated: false,
34 },
35 );
36 }
37
38 let head_end = char_boundary_at_or_before(output, TRUNCATED_HEAD_BYTES);
39 let tail_start =
40 char_boundary_at_or_after(output, original_len.saturating_sub(TRUNCATED_TAIL_BYTES));
41 let head = &output[..head_end];
42 let omitted_middle = &output[head_end..tail_start];
43 let tail = &output[tail_start..];
44 let omitted = omitted_middle.len();
45 let note = format!(
46 "...\n\n[Output truncated: showing first {head_bytes} bytes and last {tail_bytes} bytes. {omitted} bytes omitted.]",
47 head_bytes = head.len(),
48 tail_bytes = tail.len(),
49 );
50
51 // Preserve high-signal summary lines from the omitted middle (cargo test
52 // results, rustc errors, panics, completion markers). The raw tail is
53 // already included below; these snippets keep earlier failures visible
54 // without re-running `cargo test | tail` repeatedly (#242/#1450).
55 let mut combined = format!("{head}{note}");
56 let preserved = collect_summary_lines(omitted_middle);
57 if !preserved.is_empty() {
58 combined.push_str("\n\n[Preserved summary lines from omitted middle]\n");
59 combined.push_str(&preserved.join("\n"));
60 }
61 combined.push_str("\n\n[Output tail]\n");
62 combined.push_str(tail);
63
64 (
65 combined,
66 TruncationMeta {
67 original_len,
68 omitted,
69 truncated: true,
70 },
71 )
72 }
73
74 /// Extract high-signal summary lines from a chunk of output that would
75 /// otherwise be discarded by truncation. Recognises Cargo/rustc output,
76 /// generic test framework summaries, panic markers, exit-status lines,
77 /// and `Finished`/`running ...` markers. Returns at most
78 /// `MAX_PRESERVED_SUMMARY_LINES` lines, oldest-first within each match
79 /// class so the most actionable signal is at the end.
80 pub(crate) fn collect_summary_lines(text: &str) -> Vec<String> {
81 let mut preserved: Vec<String> = Vec::new();
82 for line in text.lines() {
83 if preserved.len() >= MAX_PRESERVED_SUMMARY_LINES {
84 break;
85 }
86 if is_summary_line(line) {
87 preserved.push(line.to_string());
88 }
89 }
90 preserved
91 }
92
93 /// Heuristics for "this line is worth preserving even when most of the
94 /// output is dropped." Tuned for Cargo/rustc and generic test runner
95 /// vocabulary. Intentionally conservative: false positives only cost a
96 /// handful of bytes; false negatives force the agent to re-run gates.
97 fn is_summary_line(line: &str) -> bool {
98 let trimmed = line.trim_start();
99 if trimmed.is_empty() {
100 return false;
101 }
102 // Cargo / rustc canonical markers. Note `trim_start` already stripped
103 // any leading whitespace, so match the bare word — the indentation
104 // Cargo prints (e.g. " Finished") would never reach this point.
105 if trimmed.starts_with("test result:")
106 || trimmed.starts_with("failures:")
107 || trimmed.starts_with("FAILED")
108 || trimmed.starts_with("error[")
109 || trimmed.starts_with("error:")
110 || trimmed.starts_with("warning:")
111 || trimmed.starts_with("panicked at")
112 || trimmed.starts_with("note:")
113 || trimmed.starts_with("help:")
114 || trimmed.starts_with("Finished")
115 || trimmed.starts_with("Compiling")
116 || trimmed.starts_with("Building")
117 || trimmed.starts_with("Running")
118 || trimmed.starts_with("running ")
119 || trimmed.starts_with("Doc-tests")
120 || trimmed.starts_with("---- ")
121 {
122 return true;
123 }
124 // Generic test runner vocabulary.
125 if trimmed.contains("PASS") || trimmed.contains("FAIL") || trimmed.contains("ASSERT") {
126 return true;
127 }
128 // Process-level signal lines.
129 if trimmed.starts_with("Killed")
130 || trimmed.starts_with("Aborted")
131 || trimmed.starts_with("Segmentation fault")
132 || trimmed.starts_with("Error:")
133 || trimmed.starts_with("exit status")
134 || trimmed.starts_with("exit code")
135 {
136 return true;
137 }
138 // `test some::name ... ok|FAILED|ignored` is the per-test result line in
139 // libtest. Cheap to match and useful for pinpointing the failing case.
140 if trimmed.starts_with("test ") && (trimmed.ends_with("FAILED") || trimmed.ends_with("ignored"))
141 {
142 return true;
143 }
144 false
145 }
146
147 fn char_boundary_at_or_before(text: &str, max_bytes: usize) -> usize {
148 if max_bytes >= text.len() {
149 return text.len();
150 }
151
152 let mut last_end = 0usize;
153 for (idx, ch) in text.char_indices() {
154 let end = idx.saturating_add(ch.len_utf8());
155 if end > max_bytes {
156 break;
157 }
158 last_end = end;
159 }
160
161 last_end.min(text.len())
162 }
163
164 fn char_boundary_at_or_after(text: &str, min_bytes: usize) -> usize {
165 if min_bytes >= text.len() {
166 return text.len();
167 }
168 if text.is_char_boundary(min_bytes) {
169 return min_bytes;
170 }
171 text.char_indices()
172 .map(|(idx, _)| idx)
173 .find(|&idx| idx > min_bytes)
174 .unwrap_or(text.len())
175 }
176
177 fn strip_truncation_note(text: &str) -> &str {
178 text.split_once("\n\n[Output truncated")
179 .map_or(text, |(prefix, _)| prefix)
180 }
181
182 fn truncate_chars(text: &str, max_chars: usize) -> String {
183 if text.chars().count() <= max_chars {
184 return text.to_string();
185 }
186
187 let mut end = text.len();
188 for (count, (idx, _)) in text.char_indices().enumerate() {
189 if count == max_chars {
190 end = idx;
191 break;
192 }
193 }
194
195 format!("{}...", &text[..end])
196 }
197
198 pub(crate) fn summarize_output(text: &str) -> String {
199 let stripped = strip_truncation_note(text);
200 let summary = stripped
201 .lines()
202 .take(SUMMARY_MAX_LINES)
203 .collect::<Vec<_>>()
204 .join("\n")
205 .trim()
206 .to_string();
207
208 if summary.is_empty() {
209 String::new()
210 } else {
211 truncate_chars(&summary, SUMMARY_MAX_CHARS)
212 }
213 }
214
215 #[cfg(test)]
216 mod tests {
217 use super::*;
218
219 #[test]
220 fn truncation_preserves_cargo_test_summary_lines_from_tail() {
221 let mut head = String::with_capacity(MAX_OUTPUT_SIZE + 4_000);
222 head.push_str("running 5 tests\n");
223 for i in 0..3_000 {
224 head.push_str(&format!("test test::case_{i} ... ok\n"));
225 }
226 // Pad to force tail truncation
227 while head.len() < MAX_OUTPUT_SIZE {
228 head.push_str("...padding line below threshold...\n");
229 }
230 head.push_str("\ntest result: ok. 1687 passed; 0 failed; 2 ignored\n");
231 head.push_str(" Finished `dev` profile target(s) in 4.87s\n");
232
233 let (truncated, meta) = truncate_with_meta(&head);
234 assert!(meta.truncated, "expected truncation");
235 assert!(
236 truncated.contains("test result: ok. 1687 passed"),
237 "summary line must be preserved\nGot: {}",
238 &truncated[truncated.len().saturating_sub(400)..]
239 );
240 assert!(
241 truncated.contains("Finished"),
242 "Finished marker must be preserved"
243 );
244 }
245
246 #[test]
247 fn truncation_preserves_failure_lines_from_tail() {
248 let mut head = String::with_capacity(MAX_OUTPUT_SIZE + 1_000);
249 for _ in 0..MAX_OUTPUT_SIZE {
250 head.push('a');
251 }
252 head.push_str("\nfailures:\n test::flaky_thing FAILED\n");
253 head.push_str("test result: FAILED. 0 passed; 1 failed\n");
254
255 let (truncated, _meta) = truncate_with_meta(&head);
256 assert!(truncated.contains("failures:"), "must preserve failures:");
257 assert!(truncated.contains("FAILED"), "must preserve FAILED");
258 }
259
260 #[test]
261 fn truncation_includes_raw_tail_for_shell_output() {
262 let mut output = String::new();
263 output.push_str("head-marker\n");
264 output.push_str(&"middle noise\n".repeat(3_000));
265 output.push_str("tail-marker: final compiler error\n");
266
267 let (truncated, meta) = truncate_with_meta(&output);
268
269 assert!(meta.truncated, "expected truncation");
270 assert!(truncated.contains("head-marker"));
271 assert!(
272 truncated.contains("[Output tail]"),
273 "tail section should be explicit: {truncated}"
274 );
275 assert!(
276 truncated.contains("tail-marker: final compiler error"),
277 "raw tail must remain visible"
278 );
279 }
280
281 #[test]
282 fn collect_summary_lines_skips_noise() {
283 let body = "\nblah blah\nrandom line\nokay\n\n";
284 assert!(collect_summary_lines(body).is_empty());
285 }
286
287 #[test]
288 fn collect_summary_lines_picks_rustc_errors() {
289 let body = "\
290 some preamble
291 error[E0277]: the trait `Foo` is not implemented for `Bar`
292 --> src/lib.rs:42:9
293 warning: unused variable
294 note: see help
295 ";
296 let preserved = collect_summary_lines(body);
297 assert!(preserved.iter().any(|line| line.contains("error[E0277]")));
298 assert!(preserved.iter().any(|line| line.contains("warning:")));
299 }
300 }
301
301 lines RUST