Skip to main content

everruns_core/
exec_tool_result.rs

1//! Shared shaping helpers for human-facing exec tool results.
2//!
3//! Important decision: keep the visible JSON contract stable across shell-like
4//! tools and keep pre-truncation output in the raw sidecar only. UI cards,
5//! previews, persistence hooks, and narration all depend on this split.
6
7use crate::tool_output_sanitizer::{
8    clean_exec_output, output_verbosity_budget, priority_aware_truncate, resolve_auto_mode,
9    truncate_exec_stream,
10};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ExecToolResultPayload {
14    pub stdout: String,
15    pub stderr: String,
16    pub exit_code: i32,
17    pub success: bool,
18    pub truncated: bool,
19    pub total_lines: usize,
20    pub raw_output: String,
21}
22
23impl ExecToolResultPayload {
24    pub fn new(stdout: &str, stderr: &str, exit_code: i32, output_mode: &str) -> Self {
25        let clean_stdout = clean_exec_output(stdout);
26        let clean_stderr = clean_exec_output(stderr);
27        // EVE-489: `auto` is persistence-first — successful persisted exec
28        // calls return a tiny inline summary so the model can rely on the
29        // persisted /outputs files for the full log, while failures fall back
30        // to a `normal`-sized window for in-loop debugging.
31        let effective_mode = resolve_auto_mode(output_mode, exit_code);
32        let (stdout, stderr) = if let Some(budget) = output_verbosity_budget(effective_mode) {
33            (
34                truncate_exec_stream(&clean_stdout, budget, exit_code),
35                priority_aware_truncate(&clean_stderr, budget.min(4096)),
36            )
37        } else {
38            (clean_stdout.clone(), clean_stderr.clone())
39        };
40        let truncated = stdout != clean_stdout || stderr != clean_stderr;
41        let total_lines = clean_stdout.lines().count();
42        let mut raw_output = clean_stdout;
43        if !clean_stderr.is_empty() {
44            raw_output.push_str("\n--- stderr ---\n");
45            raw_output.push_str(&clean_stderr);
46        }
47
48        Self {
49            stdout,
50            stderr,
51            exit_code,
52            success: exit_code == 0,
53            truncated,
54            total_lines,
55            raw_output,
56        }
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::ExecToolResultPayload;
63    use crate::tool_output_sanitizer::{AUTO_SUCCESS_BUDGET, NORMAL_BUDGET};
64
65    #[test]
66    fn preserves_full_raw_output_and_marks_truncation() {
67        let stdout = (0..400)
68            .map(|index| format!("line {index}"))
69            .collect::<Vec<_>>()
70            .join("\n");
71        let payload = ExecToolResultPayload::new(&stdout, "warn\n", 17, "concise");
72
73        assert_eq!(payload.exit_code, 17);
74        assert!(!payload.success);
75        assert!(payload.truncated);
76        assert_eq!(payload.total_lines, 400);
77        assert!(payload.stdout.len() < stdout.len());
78        assert!(payload.raw_output.contains("line 0"));
79        assert!(payload.raw_output.contains("line 399"));
80        assert!(payload.raw_output.contains("--- stderr ---"));
81    }
82
83    #[test]
84    fn full_mode_keeps_complete_output_inline() {
85        let payload = ExecToolResultPayload::new("alpha\nbeta\n", "", 0, "full");
86
87        assert_eq!(payload.stdout, "alpha\nbeta\n");
88        assert_eq!(payload.stderr, "");
89        assert!(!payload.truncated);
90        assert_eq!(payload.total_lines, 2);
91        assert_eq!(payload.raw_output, "alpha\nbeta\n");
92    }
93
94    // ====================================================================
95    // EVE-489: auto mode is persistence-first
96    // ====================================================================
97
98    #[test]
99    fn auto_success_produces_compact_inline_output() {
100        let stdout = (0..2000)
101            .map(|i| format!("success-line-{i}"))
102            .collect::<Vec<_>>()
103            .join("\n");
104        let payload = ExecToolResultPayload::new(&stdout, "", 0, "auto");
105
106        assert!(payload.success);
107        assert!(
108            payload.stdout.len() <= AUTO_SUCCESS_BUDGET,
109            "auto+success stdout should fit in AUTO_SUCCESS_BUDGET ({}), got {} bytes",
110            AUTO_SUCCESS_BUDGET,
111            payload.stdout.len()
112        );
113        // total_lines is computed from cleaned (untruncated) stdout
114        assert_eq!(payload.total_lines, 2000);
115        // raw_output still contains every line for the persistence hook
116        assert!(payload.raw_output.contains("success-line-0"));
117        assert!(payload.raw_output.contains("success-line-1999"));
118        assert!(payload.truncated);
119    }
120
121    #[test]
122    fn auto_failure_uses_normal_diagnostic_budget() {
123        // Build output large enough that NORMAL_BUDGET truncation kicks in
124        // but the result still has substantial diagnostic detail.
125        let mut lines = Vec::new();
126        for i in 0..200 {
127            lines.push(format!("building module {i}"));
128        }
129        lines.push("error: failed to compile".to_string());
130        lines.push("  --> src/main.rs:1:1".to_string());
131        for i in 0..2000 {
132            lines.push(format!("trailing diagnostic line {i}"));
133        }
134        let stdout = lines.join("\n");
135
136        let payload = ExecToolResultPayload::new(&stdout, "stderr details\n", 1, "auto");
137
138        assert!(!payload.success);
139        assert_eq!(payload.exit_code, 1);
140        // Auto + failure should give a diagnostic window, not the tiny success budget.
141        assert!(
142            payload.stdout.len() > AUTO_SUCCESS_BUDGET,
143            "auto+failure stdout should exceed AUTO_SUCCESS_BUDGET to fit diagnostics, got {} bytes",
144            payload.stdout.len()
145        );
146        assert!(
147            payload.stdout.len() <= NORMAL_BUDGET,
148            "auto+failure stdout should be capped at NORMAL_BUDGET ({}), got {} bytes",
149            NORMAL_BUDGET,
150            payload.stdout.len()
151        );
152        // Error region is preserved through priority-aware truncation.
153        assert!(
154            payload.stdout.contains("error: failed to compile"),
155            "error line must be preserved in failure diagnostic output"
156        );
157        // raw_output still has the full content for persistence.
158        assert!(payload.raw_output.contains("building module 0"));
159        assert!(payload.raw_output.contains("trailing diagnostic line 1999"));
160        assert!(payload.raw_output.contains("--- stderr ---"));
161    }
162
163    #[test]
164    fn auto_small_success_output_is_unchanged() {
165        let payload = ExecToolResultPayload::new("ok\n", "", 0, "auto");
166        assert!(payload.success);
167        assert_eq!(payload.stdout, "ok\n");
168        assert!(!payload.truncated);
169    }
170
171    #[test]
172    fn explicit_normal_still_uses_normal_budget_on_success() {
173        let stdout = (0..2000)
174            .map(|i| format!("line-{i}"))
175            .collect::<Vec<_>>()
176            .join("\n");
177        let payload = ExecToolResultPayload::new(&stdout, "", 0, "normal");
178
179        assert!(payload.success);
180        // Normal on success returns NORMAL_BUDGET worth of inline output,
181        // not the auto-success compact summary.
182        assert!(
183            payload.stdout.len() > AUTO_SUCCESS_BUDGET,
184            "explicit normal mode should not compact to auto-success budget on success"
185        );
186        assert!(payload.stdout.len() <= NORMAL_BUDGET);
187    }
188
189    #[test]
190    fn successful_source_search_preserves_leading_matches() {
191        let mut lines = vec![
192            "src/runtime.rs:10: first relevant match".to_string(),
193            "src/runtime.rs:11: second relevant match".to_string(),
194        ];
195        lines.extend((0..1200).map(|i| format!("src/module_{i}.rs: ordinary source line")));
196        lines.extend((0..300).map(|i| format!("src/errors_{i}.rs: struct ErrorContext{i}")));
197        let stdout = lines.join("\n");
198
199        let payload = ExecToolResultPayload::new(&stdout, "", 0, "normal");
200
201        assert!(payload.truncated);
202        assert!(payload.stdout.contains("first relevant match"));
203        assert!(payload.stdout.contains("second relevant match"));
204    }
205
206    #[test]
207    fn raw_output_preserved_across_modes() {
208        let stdout = (0..5000)
209            .map(|i| format!("line-{i}"))
210            .collect::<Vec<_>>()
211            .join("\n");
212
213        for mode in ["auto", "silent", "concise", "normal", "verbose", "full"] {
214            for exit_code in [0, 1] {
215                let payload = ExecToolResultPayload::new(&stdout, "err\n", exit_code, mode);
216                assert!(
217                    payload.raw_output.contains("line-0"),
218                    "raw_output should contain head for mode={mode} exit={exit_code}"
219                );
220                assert!(
221                    payload.raw_output.contains("line-4999"),
222                    "raw_output should contain tail for mode={mode} exit={exit_code}"
223                );
224                assert!(
225                    payload.raw_output.contains("--- stderr ---"),
226                    "raw_output should contain stderr marker for mode={mode} exit={exit_code}"
227                );
228            }
229        }
230    }
231}