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