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