Skip to main content

everruns_core/capabilities/
tool_output_persistence.rs

1// Tool Output Persistence Capability (EVE-222, EVE-245, EVE-562)
2//
3// Persists full exec tool output to session VFS before truncation,
4// enabling lossless retrieval via read_file/grep. The LLM gets a
5// truncated summary with `full_output` path and `output_files`
6// array pointing to the persisted files.
7//
8// Design decisions:
9// - Implemented as PostToolExecHook, not baked into each tool — VFS
10//   persistence is a cross-cutting concern the tool shouldn't know about
11// - Reads `persist_output` hint from ToolDefinition to decide what to persist
12// - Writes stdout to /outputs/{safe_id}.stdout, stderr to
13//   /outputs/{safe_id}.stderr when stderr is persisted (session-scoped)
14// - Injects `output_files` array into result for agent to read selectively
15// - Graceful degradation: skip silently if file_store is unavailable
16// - Runs as an always-on final hook before EVE-225 hard-limit truncation
17// - EVE-245: annotates truncated stdout with file reference so agent knows
18//   it can read_file the full output with offset/limit
19// - EVE-562: after persistence, applies the requested output mode to
20//   model-facing stdout/stderr. Default auto mode uses a compact success
21//   window; explicit modes retain their fixed budgets.
22
23use std::sync::Arc;
24
25use async_trait::async_trait;
26use serde_json::json;
27
28use super::{Capability, CapabilityLocalization, CapabilityStatus};
29use crate::atoms::PostToolExecHook;
30use crate::tool_output_sanitizer::{
31    output_verbosity_budget, priority_aware_truncate, resolve_auto_mode, truncate_exec_stream,
32};
33use crate::tool_types::{ToolCall, ToolDefinition, ToolResult};
34use crate::traits::{SessionFileSystem, ToolContext};
35use crate::typed_id::SessionId;
36
37/// Max bytes persisted per output stream file to avoid storage exhaustion.
38const MAX_PERSISTED_STREAM_BYTES: usize = 1024 * 1024; // 1 MiB
39const OUTPUT_DIR: &str = "/outputs";
40
41/// Result of persisting large exec output to session VFS.
42pub struct PersistResult {
43    /// Path to the persisted stdout file in session VFS form
44    /// (e.g. `/outputs/{safe_id}.stdout`).
45    pub stdout_path: Option<String>,
46    /// Path to the persisted stderr file in session VFS form, if non-empty
47    /// stderr was persisted.
48    pub stderr_path: Option<String>,
49    /// Total line count of the persisted stdout.
50    pub stdout_total_lines: usize,
51}
52
53/// Persist exec output to session VFS.
54///
55/// Writes stdout to `/outputs/{safe_id}.stdout` and stderr to
56/// `/outputs/{safe_id}.stderr` (if non-empty). Returns paths and metadata.
57///
58/// This is the shared helper that any sandbox tool or hook can call.
59pub async fn persist_output(
60    file_store: &Arc<dyn SessionFileSystem>,
61    session_id: SessionId,
62    tool_call_id: &str,
63    stdout: &str,
64    stderr: &str,
65) -> Option<PersistResult> {
66    if stdout.is_empty() && stderr.is_empty() {
67        return None;
68    }
69
70    let safe_id: String = tool_call_id
71        .chars()
72        .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
73        .collect();
74    if safe_id.is_empty() {
75        return None;
76    }
77
78    let mut result = PersistResult {
79        stdout_path: None,
80        stderr_path: None,
81        stdout_total_lines: 0,
82    };
83
84    // Persist stdout whenever present so verbosity-truncated results remain recoverable.
85    if !stdout.is_empty() {
86        let stdout_to_persist = truncate_for_persistence(stdout, MAX_PERSISTED_STREAM_BYTES);
87        let path = format!("{OUTPUT_DIR}/{safe_id}.stdout");
88        result.stdout_total_lines = stdout.lines().count();
89        if file_store
90            .write_file(session_id, &path, &stdout_to_persist, "utf-8")
91            .await
92            .is_ok()
93        {
94            result.stdout_path = Some(path);
95        }
96    }
97
98    // Persist stderr separately whenever present.
99    if !stderr.is_empty() {
100        let stderr_to_persist = truncate_for_persistence(stderr, MAX_PERSISTED_STREAM_BYTES);
101        let path = format!("{OUTPUT_DIR}/{safe_id}.stderr");
102        if file_store
103            .write_file(session_id, &path, &stderr_to_persist, "utf-8")
104            .await
105            .is_ok()
106        {
107            result.stderr_path = Some(path);
108        }
109    }
110
111    // Only return Some if at least one file was persisted
112    if result.stdout_path.is_some() || result.stderr_path.is_some() {
113        Some(result)
114    } else {
115        None
116    }
117}
118
119/// Back-compat wrapper for older callers. Despite the historical name, this now
120/// persists any non-empty opted-in output so verbosity-truncated results are
121/// recoverable through `read_file`.
122pub async fn persist_large_output(
123    file_store: &Arc<dyn SessionFileSystem>,
124    session_id: SessionId,
125    tool_call_id: &str,
126    stdout: &str,
127    stderr: &str,
128) -> Option<PersistResult> {
129    persist_output(file_store, session_id, tool_call_id, stdout, stderr).await
130}
131
132/// Truncate persisted stream content to a bounded size with a clear marker.
133fn truncate_for_persistence(text: &str, max_bytes: usize) -> String {
134    if text.len() <= max_bytes {
135        return text.to_string();
136    }
137
138    // Keep valid UTF-8 boundary at or below the byte cap.
139    let mut end = max_bytes;
140    while end > 0 && !text.is_char_boundary(end) {
141        end -= 1;
142    }
143    let mut truncated = text[..end].to_string();
144    truncated.push_str("\n\n[output truncated before persistence due to size limit]");
145    truncated
146}
147
148/// Build the annotated truncated stdout string with file reference.
149pub fn annotate_truncated_output(truncated: &str, file_path: &str, full_size: usize) -> String {
150    let size_kb = full_size / 1024;
151    format!(
152        "{truncated}\n\n[full output saved to {file_path} ({size_kb} KiB) — use read_file with offset/limit]"
153    )
154}
155
156/// After successful persistence, compact the inline model-facing output fields
157/// in the result JSON so the LLM never carries unbounded content forward.
158///
159/// Budget policy (EVE-562): resolve the requested mode against the process
160/// outcome. Default `auto` uses the compact success window or normal failure
161/// window; explicit modes retain their documented fixed budgets.
162///
163/// The same budget applies to both `stdout`/`output` and `stderr`. Full content
164/// is always recoverable from the persisted `/outputs/` files.
165pub fn compact_persisted_result_for_model(
166    obj: &mut serde_json::Map<String, serde_json::Value>,
167    stdout_path: &str,
168    stdout_full_size: usize,
169    output_mode: &str,
170) {
171    let exit_code = result_exit_code(obj);
172    let budget = output_verbosity_budget(resolve_auto_mode(output_mode, exit_code));
173
174    // Compact stdout (or output fallback field) and append file pointer.
175    for field in ["stdout", "output"] {
176        if let Some(text) = obj.get(field).and_then(|v| v.as_str()) {
177            let compacted =
178                compact_with_annotation(text, budget, exit_code, stdout_path, stdout_full_size);
179            obj.insert(field.to_string(), json!(compacted));
180            break; // only one of stdout/output is expected
181        }
182    }
183
184    // Compact stderr inline if present (no separate file pointer needed — already in output_files).
185    compact_stderr_inline(obj, budget);
186}
187
188/// Compact the inline `stderr` field to `budget` bytes using outcome-aware truncation.
189///
190/// Called from `compact_persisted_result_for_model` and also directly when stderr was
191/// persisted but there is no stdout file (so `compact_persisted_result_for_model` was
192/// not invoked).
193fn compact_stderr_inline(
194    obj: &mut serde_json::Map<String, serde_json::Value>,
195    budget: Option<usize>,
196) {
197    if let Some(budget) = budget
198        && let Some(stderr_text) = obj.get("stderr").and_then(|v| v.as_str())
199        && stderr_text.len() > budget
200    {
201        let compacted = priority_aware_truncate(stderr_text, budget);
202        obj.insert("stderr".to_string(), json!(compacted));
203    }
204}
205
206/// Return a sentinel exit code suitable for outcome-aware truncation.
207fn result_exit_code(obj: &serde_json::Map<String, serde_json::Value>) -> i32 {
208    if let Some(code) = obj.get("exit_code").and_then(|v| v.as_i64()) {
209        return if code == 0 { 0 } else { 1 };
210    }
211    if let Some(ok) = obj.get("success").and_then(|v| v.as_bool()) {
212        return if ok { 0 } else { 1 };
213    }
214    // Default to success when neither field is present (non-exec tools).
215    0
216}
217
218/// Compact `text` to `budget` bytes and append a file-reference annotation.
219fn compact_with_annotation(
220    text: &str,
221    budget: Option<usize>,
222    exit_code: i32,
223    file_path: &str,
224    full_size: usize,
225) -> String {
226    let compacted = budget.filter(|budget| text.len() > *budget).map_or_else(
227        || text.to_string(),
228        |budget| truncate_exec_stream(text, budget, exit_code),
229    );
230    annotate_truncated_output(&compacted, file_path, full_size)
231}
232
233pub const TOOL_OUTPUT_PERSISTENCE_CAPABILITY_ID: &str = "tool_output_persistence";
234
235/// Capability that persists full tool output to session VFS.
236pub struct ToolOutputPersistenceCapability;
237
238impl Capability for ToolOutputPersistenceCapability {
239    fn id(&self) -> &str {
240        TOOL_OUTPUT_PERSISTENCE_CAPABILITY_ID
241    }
242
243    fn name(&self) -> &str {
244        "Tool Output Persistence"
245    }
246
247    fn description(&self) -> &str {
248        "Persists full exec tool output to session VFS before truncation, \
249         enabling lossless retrieval via read_file or grep."
250    }
251
252    fn localizations(&self) -> Vec<CapabilityLocalization> {
253        vec![CapabilityLocalization::text(
254            "uk",
255            "Збереження виводу інструментів",
256            "Зберігає повний вивід інструментів виконання у VFS сесії перед обрізанням, що дає змогу отримати його без втрат через read_file або grep.",
257        )]
258    }
259
260    fn status(&self) -> CapabilityStatus {
261        CapabilityStatus::Available
262    }
263
264    fn dependencies(&self) -> Vec<&'static str> {
265        vec!["session_file_system"]
266    }
267
268    fn post_tool_exec_hooks(&self) -> Vec<Arc<dyn PostToolExecHook>> {
269        vec![Arc::new(PersistOutputHook)]
270    }
271}
272
273/// Hook that persists tool output to VFS when `persist_output` hint is set.
274pub struct PersistOutputHook;
275
276#[async_trait]
277impl PostToolExecHook for PersistOutputHook {
278    async fn after_exec(
279        &self,
280        tool_call: &ToolCall,
281        tool_def: &ToolDefinition,
282        result: &mut ToolResult,
283        context: &ToolContext,
284    ) {
285        // Only persist if tool declares persist_output hint
286        if tool_def.hints().persist_output != Some(true) {
287            return;
288        }
289
290        if result
291            .result
292            .as_ref()
293            .and_then(|value| value.get("output_files"))
294            .is_some()
295        {
296            return;
297        }
298
299        // Take raw_output (pre-truncation cleaned output) if available.
300        // Falls back to extracting from the (truncated) result JSON.
301        let output_text = result.raw_output.take().unwrap_or_else(|| {
302            result
303                .result
304                .as_ref()
305                .map(extract_output_text)
306                .unwrap_or_default()
307        });
308        if output_text.is_empty() {
309            return;
310        }
311
312        // Need file_store from context
313        let Some(ref file_store) = context.file_store else {
314            return;
315        };
316
317        // Split into stdout/stderr for the shared helper
318        let (stdout, stderr) = split_output_streams(&output_text);
319
320        if let Some(persist_result) = persist_large_output(
321            file_store,
322            context.session_id,
323            &tool_call.id,
324            stdout,
325            stderr,
326        )
327        .await
328        {
329            let output_mode = tool_call
330                .arguments
331                .get("output")
332                .and_then(|value| value.as_str())
333                .unwrap_or("auto");
334            // Enrich result JSON with file references and compact inline output (EVE-562).
335            if let Some(ref mut json_val) = result.result
336                && let Some(obj) = json_val.as_object_mut()
337            {
338                let mut output_files = Vec::new();
339
340                if let Some(ref path) = persist_result.stdout_path {
341                    let display_path = file_store.display_path(path);
342                    compact_persisted_result_for_model(
343                        obj,
344                        &display_path,
345                        stdout.len(),
346                        output_mode,
347                    );
348
349                    output_files.push(display_path.clone());
350                    // full_output points to the stdout file only; stderr (if persisted)
351                    // is available via the output_files array
352                    obj.insert("full_output".to_string(), json!(display_path));
353                    obj.insert(
354                        "total_lines".to_string(),
355                        json!(persist_result.stdout_total_lines),
356                    );
357                }
358
359                if let Some(ref path) = persist_result.stderr_path {
360                    output_files.push(file_store.display_path(path));
361                    // Compact stderr inline when stdout was not persisted (if stdout was
362                    // persisted, compact_persisted_result_for_model already handled this).
363                    if persist_result.stdout_path.is_none() {
364                        let exit_code = result_exit_code(obj);
365                        let budget =
366                            output_verbosity_budget(resolve_auto_mode(output_mode, exit_code));
367                        compact_stderr_inline(obj, budget);
368                    }
369                }
370
371                if !output_files.is_empty() {
372                    obj.insert("output_files".to_string(), json!(output_files));
373                }
374            }
375        }
376    }
377}
378
379/// Split combined output text into stdout and stderr streams.
380///
381/// The raw output from exec tools uses `\n--- stderr ---\n` as a separator
382/// (see `bashkit_shell.rs` and other sandbox tools). Uses `rfind` to split at
383/// the *last* occurrence, since the separator is injected by our tools and
384/// shouldn't appear more than once — but if stdout happens to contain the
385/// marker text, taking the last match minimizes corruption.
386/// If no separator is found, the entire text is treated as stdout.
387fn split_output_streams(text: &str) -> (&str, &str) {
388    const STDERR_SEPARATOR: &str = "\n--- stderr ---\n";
389    if let Some(pos) = text.rfind(STDERR_SEPARATOR) {
390        (&text[..pos], &text[pos + STDERR_SEPARATOR.len()..])
391    } else {
392        (text, "")
393    }
394}
395
396/// Extract readable text content from a tool result JSON value.
397///
398/// Looks for common exec tool output fields: `stdout`, `stderr`, `output`.
399/// Concatenates them into a single string for persistence.
400fn extract_output_text(json: &serde_json::Value) -> String {
401    let mut parts = Vec::new();
402
403    if let Some(stdout) = json.get("stdout").and_then(|v| v.as_str())
404        && !stdout.is_empty()
405    {
406        parts.push(stdout);
407    }
408    if let Some(stderr) = json.get("stderr").and_then(|v| v.as_str())
409        && !stderr.is_empty()
410    {
411        if !parts.is_empty() {
412            parts.push("\n--- stderr ---\n");
413        }
414        parts.push(stderr);
415    }
416    // Fallback: if no stdout/stderr, try "output" field (daytona_exec)
417    if parts.is_empty()
418        && let Some(output) = json.get("output").and_then(|v| v.as_str())
419        && !output.is_empty()
420    {
421        parts.push(output);
422    }
423
424    parts.join("")
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crate::tool_output_sanitizer::EXEC_OUTPUT_BUDGET;
431
432    #[test]
433    fn test_extract_output_text_stdout_stderr() {
434        let json = json!({
435            "stdout": "hello world",
436            "stderr": "warning: unused variable",
437            "exit_code": 0
438        });
439        let text = extract_output_text(&json);
440        assert!(text.contains("hello world"));
441        assert!(text.contains("warning: unused variable"));
442        assert!(text.contains("--- stderr ---"));
443    }
444
445    #[test]
446    fn test_extract_output_text_stdout_only() {
447        let json = json!({
448            "stdout": "hello",
449            "stderr": "",
450            "exit_code": 0
451        });
452        assert_eq!(extract_output_text(&json), "hello");
453    }
454
455    #[test]
456    fn test_extract_output_text_output_field() {
457        let json = json!({
458            "output": "combined output",
459            "exit_code": 0
460        });
461        assert_eq!(extract_output_text(&json), "combined output");
462    }
463
464    #[test]
465    fn test_extract_output_text_empty() {
466        let json = json!({ "exit_code": 0 });
467        assert_eq!(extract_output_text(&json), "");
468    }
469
470    #[test]
471    fn test_extract_output_text_prefers_stdout_over_output() {
472        let json = json!({
473            "stdout": "from stdout",
474            "output": "from output",
475            "exit_code": 0
476        });
477        // stdout takes precedence — output field is fallback
478        let text = extract_output_text(&json);
479        assert!(text.contains("from stdout"));
480        assert!(!text.contains("from output"));
481    }
482
483    #[test]
484    fn test_capability_metadata() {
485        let cap = ToolOutputPersistenceCapability;
486        assert_eq!(cap.id(), "tool_output_persistence");
487        assert!(!cap.post_tool_exec_hooks().is_empty());
488        assert!(cap.dependencies().contains(&"session_file_system"));
489    }
490
491    #[test]
492    fn test_split_output_streams_both() {
493        let text = "hello world\n--- stderr ---\nwarning: unused";
494        let (stdout, stderr) = split_output_streams(text);
495        assert_eq!(stdout, "hello world");
496        assert_eq!(stderr, "warning: unused");
497    }
498
499    #[test]
500    fn test_split_output_streams_stdout_only() {
501        let text = "hello world\nline two";
502        let (stdout, stderr) = split_output_streams(text);
503        assert_eq!(stdout, "hello world\nline two");
504        assert!(stderr.is_empty());
505    }
506
507    #[test]
508    fn test_split_output_streams_empty() {
509        let (stdout, stderr) = split_output_streams("");
510        assert!(stdout.is_empty());
511        assert!(stderr.is_empty());
512    }
513
514    #[test]
515    fn test_annotate_truncated_output() {
516        let annotated = annotate_truncated_output(
517            "truncated text...",
518            "/workspace/outputs/abc.stdout",
519            50 * 1024,
520        );
521        assert!(annotated.contains("truncated text..."));
522        assert!(annotated.contains("/workspace/outputs/abc.stdout"));
523        assert!(annotated.contains("50 KiB"));
524        assert!(annotated.contains("read_file"));
525    }
526
527    #[test]
528    fn test_annotate_truncated_output_small() {
529        let annotated = annotate_truncated_output("short", "/workspace/outputs/x.stdout", 1024);
530        assert!(annotated.contains("1 KiB"));
531        assert!(annotated.contains("read_file with offset/limit"));
532    }
533
534    #[test]
535    fn test_truncate_for_persistence_noop_when_under_limit() {
536        let text = "hello";
537        assert_eq!(truncate_for_persistence(text, 1024), text);
538    }
539
540    #[test]
541    fn test_truncate_for_persistence_adds_marker_when_over_limit() {
542        let text = "x".repeat(128);
543        let out = truncate_for_persistence(&text, 32);
544        assert!(out.len() > 32);
545        assert!(out.starts_with(&"x".repeat(32)));
546        assert!(out.contains("output truncated before persistence"));
547    }
548
549    // --- persist_large_output tests ---
550
551    use crate::error::Result;
552    use crate::session_file::{FileInfo, FileStat, GrepMatch, SessionFile};
553    use crate::traits::SessionFileSystem;
554    use chrono::Utc;
555    use std::collections::HashMap;
556    use std::sync::Mutex;
557    use uuid::Uuid;
558
559    #[derive(Default)]
560    struct MockFileStore {
561        files: Mutex<HashMap<String, String>>,
562    }
563
564    impl MockFileStore {
565        fn content(&self, path: &str) -> Option<String> {
566            self.files.lock().unwrap().get(path).cloned()
567        }
568    }
569
570    #[async_trait]
571    impl SessionFileSystem for MockFileStore {
572        fn is_mount_resolver(&self) -> bool {
573            false
574        }
575
576        async fn read_file(
577            &self,
578            _session_id: SessionId,
579            _path: &str,
580        ) -> Result<Option<SessionFile>> {
581            Ok(None)
582        }
583
584        async fn write_file(
585            &self,
586            _session_id: SessionId,
587            path: &str,
588            content: &str,
589            _encoding: &str,
590        ) -> Result<SessionFile> {
591            self.files
592                .lock()
593                .unwrap()
594                .insert(path.to_string(), content.to_string());
595            Ok(SessionFile {
596                id: Uuid::new_v4(),
597                session_id: Uuid::nil(),
598                path: path.to_string(),
599                name: path.rsplit('/').next().unwrap_or("").to_string(),
600                content: Some(content.to_string()),
601                encoding: "utf-8".to_string(),
602                is_directory: false,
603                is_readonly: false,
604                size_bytes: content.len() as i64,
605                created_at: Utc::now(),
606                updated_at: Utc::now(),
607            })
608        }
609
610        async fn delete_file(
611            &self,
612            _session_id: SessionId,
613            _path: &str,
614            _recursive: bool,
615        ) -> Result<bool> {
616            Ok(false)
617        }
618
619        async fn list_directory(
620            &self,
621            _session_id: SessionId,
622            _path: &str,
623        ) -> Result<Vec<FileInfo>> {
624            Ok(vec![])
625        }
626
627        async fn stat_file(&self, _session_id: SessionId, _path: &str) -> Result<Option<FileStat>> {
628            Ok(None)
629        }
630
631        async fn grep_files(
632            &self,
633            _session_id: SessionId,
634            _pattern: &str,
635            _path_pattern: Option<&str>,
636        ) -> Result<Vec<GrepMatch>> {
637            Ok(vec![])
638        }
639
640        async fn create_directory(&self, _session_id: SessionId, _path: &str) -> Result<FileInfo> {
641            Err(anyhow::anyhow!("not implemented").into())
642        }
643    }
644
645    fn test_session_id() -> SessionId {
646        SessionId::from(Uuid::nil())
647    }
648
649    fn persistence_tool_def() -> ToolDefinition {
650        use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolHints, ToolPolicy};
651
652        ToolDefinition::Builtin(BuiltinTool {
653            name: "bash".to_string(),
654            display_name: None,
655            description: "execute a command".to_string(),
656            parameters: json!({}),
657            policy: ToolPolicy::Auto,
658            category: None,
659            deferrable: DeferrablePolicy::default(),
660            hints: ToolHints::default().with_persist_output(true),
661            full_parameters: None,
662        })
663    }
664
665    #[tokio::test]
666    async fn test_hook_honors_explicit_normal_and_persists_losslessly() {
667        let mut lines = vec!["src/runtime.rs:10: first relevant match".to_string()];
668        lines.extend((0..1200).map(|i| format!("src/module_{i}.rs: ordinary source line")));
669        lines.extend((0..300).map(|i| format!("src/errors_{i}.rs: struct ErrorContext{i}")));
670        let full_output = lines.join("\n");
671        let visible_output =
672            crate::tool_output_sanitizer::truncate_exec_stream(&full_output, NORMAL_BUDGET, 0);
673        let store = Arc::new(MockFileStore::default());
674        let mut context = ToolContext::new(test_session_id());
675        context.file_store = Some(store.clone());
676        let tool_call = ToolCall {
677            id: "call-normal".to_string(),
678            name: "bash".to_string(),
679            arguments: json!({"command": "git grep Error", "output": "normal"}),
680        };
681        let mut result = ToolResult {
682            tool_call_id: tool_call.id.clone(),
683            result: Some(json!({
684                "stdout": visible_output,
685                "stderr": "",
686                "exit_code": 0,
687                "success": true,
688            })),
689            images: None,
690            error: None,
691            connection_required: None,
692            raw_output: Some(full_output.clone()),
693        };
694
695        PersistOutputHook
696            .after_exec(&tool_call, &persistence_tool_def(), &mut result, &context)
697            .await;
698
699        let inline = result.result.as_ref().unwrap()["stdout"].as_str().unwrap();
700        assert!(inline.len() > AUTO_SUCCESS_BUDGET + 200);
701        assert!(inline.len() <= NORMAL_BUDGET + 200);
702        assert!(inline.contains("first relevant match"));
703        assert_eq!(
704            store.content("/outputs/call-normal.stdout").as_deref(),
705            Some(full_output.as_str())
706        );
707    }
708
709    #[tokio::test]
710    async fn test_persist_large_output_persists_stdout_below_budget() {
711        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::default());
712        let small = "a".repeat(EXEC_OUTPUT_BUDGET);
713        let result = persist_large_output(&store, test_session_id(), "call-1", &small, "").await;
714        let r = result.expect("should persist non-empty stdout");
715        assert_eq!(r.stdout_path.as_deref(), Some("/outputs/call-1.stdout"));
716    }
717
718    #[tokio::test]
719    async fn test_persist_large_output_persists_stdout_above_budget() {
720        let mock = Arc::new(MockFileStore::default());
721        let store: Arc<dyn SessionFileSystem> = mock.clone();
722        let large = "x".repeat(EXEC_OUTPUT_BUDGET + 1);
723        let result = persist_large_output(&store, test_session_id(), "call-2", &large, "").await;
724        let r = result.expect("should persist");
725        assert!(r.stdout_path.is_some());
726        assert_eq!(r.stdout_path.as_deref(), Some("/outputs/call-2.stdout"));
727        assert!(r.stderr_path.is_none());
728        assert_eq!(r.stdout_total_lines, 1);
729        // Verify content was written
730        assert_eq!(
731            mock.content("/outputs/call-2.stdout").unwrap().len(),
732            large.len()
733        );
734    }
735
736    #[tokio::test]
737    async fn test_persist_large_output_caps_persisted_stdout_size() {
738        let mock = Arc::new(MockFileStore::default());
739        let store: Arc<dyn SessionFileSystem> = mock.clone();
740        let huge = "x".repeat(MAX_PERSISTED_STREAM_BYTES + 2048);
741        let result = persist_large_output(&store, test_session_id(), "call-cap", &huge, "").await;
742        assert!(result.is_some());
743        let persisted = mock.content("/outputs/call-cap.stdout").unwrap();
744        assert!(persisted.len() < huge.len());
745        assert!(persisted.contains("output truncated before persistence"));
746    }
747
748    #[tokio::test]
749    async fn test_persist_large_output_persists_stderr_above_threshold() {
750        let mock = Arc::new(MockFileStore::default());
751        let store: Arc<dyn SessionFileSystem> = mock.clone();
752        let large_stderr = "e".repeat(4097);
753        let result =
754            persist_large_output(&store, test_session_id(), "call-3", "small", &large_stderr).await;
755        let r = result.expect("should persist stderr");
756        assert_eq!(r.stdout_path.as_deref(), Some("/outputs/call-3.stdout"));
757        assert_eq!(r.stderr_path.as_deref(), Some("/outputs/call-3.stderr"));
758        assert_eq!(mock.content("/outputs/call-3.stderr").unwrap().len(), 4097);
759    }
760
761    #[tokio::test]
762    async fn test_persist_large_output_both_stdout_and_stderr() {
763        let mock = Arc::new(MockFileStore::default());
764        let store: Arc<dyn SessionFileSystem> = mock.clone();
765        let large_stdout = "o".repeat(EXEC_OUTPUT_BUDGET + 100);
766        let large_stderr = "e".repeat(5000);
767        let result = persist_large_output(
768            &store,
769            test_session_id(),
770            "call-4",
771            &large_stdout,
772            &large_stderr,
773        )
774        .await;
775        let r = result.expect("should persist both");
776        assert!(r.stdout_path.is_some());
777        assert!(r.stderr_path.is_some());
778    }
779
780    #[tokio::test]
781    async fn test_persist_large_output_sanitizes_id() {
782        let mock = Arc::new(MockFileStore::default());
783        let store: Arc<dyn SessionFileSystem> = mock.clone();
784        let large = "x".repeat(EXEC_OUTPUT_BUDGET + 1);
785        let result =
786            persist_large_output(&store, test_session_id(), "call/../../../etc", &large, "").await;
787        let r = result.expect("should persist with sanitized id");
788        // Path traversal chars stripped, only alphanumeric + dash + underscore kept
789        assert_eq!(r.stdout_path.as_deref(), Some("/outputs/calletc.stdout"));
790    }
791
792    #[tokio::test]
793    async fn test_persist_large_output_empty_id_returns_none() {
794        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::default());
795        let large = "x".repeat(EXEC_OUTPUT_BUDGET + 1);
796        let result = persist_large_output(&store, test_session_id(), "///...", &large, "").await;
797        assert!(result.is_none());
798    }
799
800    #[tokio::test]
801    async fn test_persist_large_output_line_count() {
802        let mock = Arc::new(MockFileStore::default());
803        let store: Arc<dyn SessionFileSystem> = mock.clone();
804        // Create multi-line output that exceeds budget
805        let line = "x".repeat(200);
806        let lines: Vec<&str> = std::iter::repeat_n(line.as_str(), 100).collect();
807        let large = lines.join("\n");
808        assert!(large.len() > EXEC_OUTPUT_BUDGET);
809        let result =
810            persist_large_output(&store, test_session_id(), "call-lines", &large, "").await;
811        let r = result.expect("should persist");
812        assert_eq!(r.stdout_total_lines, 100);
813    }
814
815    // -------------------------------------------------------------------------
816    // compact_persisted_result_for_model (EVE-562)
817    // -------------------------------------------------------------------------
818    use crate::tool_output_sanitizer::{AUTO_SUCCESS_BUDGET, NORMAL_BUDGET};
819
820    #[test]
821    fn test_compact_success_caps_stdout_to_auto_success_budget() {
822        let large_stdout = "x".repeat(NORMAL_BUDGET + 1);
823        let large_stdout_len = large_stdout.len();
824        let mut obj = serde_json::Map::new();
825        obj.insert("stdout".to_string(), json!(large_stdout));
826        obj.insert("exit_code".to_string(), json!(0));
827
828        compact_persisted_result_for_model(
829            &mut obj,
830            "/outputs/call.stdout",
831            large_stdout_len,
832            "auto",
833        );
834
835        let inline = obj["stdout"].as_str().unwrap();
836        // Inline stdout must be compact (budget + annotation pointer).
837        assert!(
838            inline.len() <= AUTO_SUCCESS_BUDGET + 200,
839            "inline stdout ({} bytes) should be ≤ AUTO_SUCCESS_BUDGET + pointer",
840            inline.len()
841        );
842        assert!(
843            inline.contains("full output saved to"),
844            "must have file pointer"
845        );
846    }
847
848    #[test]
849    fn test_compact_failure_caps_stdout_to_normal_budget() {
850        let large_stdout = "y".repeat(NORMAL_BUDGET * 2);
851        let large_stdout_len = large_stdout.len();
852        let mut obj = serde_json::Map::new();
853        obj.insert("stdout".to_string(), json!(large_stdout));
854        obj.insert("exit_code".to_string(), json!(1));
855
856        compact_persisted_result_for_model(
857            &mut obj,
858            "/outputs/call.stdout",
859            large_stdout_len,
860            "auto",
861        );
862
863        let inline = obj["stdout"].as_str().unwrap();
864        assert!(
865            inline.len() <= NORMAL_BUDGET + 200,
866            "failure stdout ({} bytes) should be ≤ NORMAL_BUDGET + pointer",
867            inline.len()
868        );
869        assert!(
870            inline.contains("full output saved to"),
871            "must have file pointer"
872        );
873    }
874
875    #[test]
876    fn test_compact_small_stdout_unchanged_except_annotation() {
877        let small = "hello world";
878        let mut obj = serde_json::Map::new();
879        obj.insert("stdout".to_string(), json!(small));
880        obj.insert("exit_code".to_string(), json!(0));
881
882        compact_persisted_result_for_model(&mut obj, "/outputs/call.stdout", small.len(), "auto");
883
884        let inline = obj["stdout"].as_str().unwrap();
885        assert!(
886            inline.starts_with("hello world"),
887            "small content must be preserved"
888        );
889        assert!(
890            inline.contains("full output saved to"),
891            "must have file pointer"
892        );
893    }
894
895    #[test]
896    fn test_compact_oversized_full_mode_stays_full() {
897        let large = "z".repeat(NORMAL_BUDGET * 3);
898        let large_len = large.len();
899        let mut obj = serde_json::Map::new();
900        obj.insert("stdout".to_string(), json!(large.clone()));
901        obj.insert("exit_code".to_string(), json!(0));
902
903        compact_persisted_result_for_model(&mut obj, "/outputs/call.stdout", large_len, "full");
904
905        let inline = obj["stdout"].as_str().unwrap();
906        assert!(inline.starts_with(&large));
907        assert!(inline.contains("full output saved to"));
908    }
909
910    #[test]
911    fn test_compact_explicit_normal_preserves_leading_search_matches() {
912        let mut lines = vec![
913            "src/runtime.rs:10: query_history registration".to_string(),
914            "src/runtime.rs:11: history capability".to_string(),
915        ];
916        lines.extend((0..1200).map(|i| format!("src/module_{i}.rs: ordinary source line")));
917        lines.extend((0..300).map(|i| format!("src/errors_{i}.rs: struct ErrorContext{i}")));
918        let output = lines.join("\n");
919        let mut obj = serde_json::Map::new();
920        obj.insert("stdout".to_string(), json!(output.clone()));
921        obj.insert("exit_code".to_string(), json!(0));
922
923        compact_persisted_result_for_model(
924            &mut obj,
925            "/outputs/call.stdout",
926            output.len(),
927            "normal",
928        );
929
930        let inline = obj["stdout"].as_str().unwrap();
931        assert!(inline.len() > AUTO_SUCCESS_BUDGET + 200);
932        assert!(inline.len() <= NORMAL_BUDGET + 200);
933        assert!(inline.contains("query_history registration"));
934        assert!(inline.contains("history capability"));
935        assert!(inline.contains("full output saved to"));
936    }
937
938    #[test]
939    fn test_compact_large_stderr_capped_on_failure() {
940        let large_stderr = "e".repeat(NORMAL_BUDGET * 2);
941        let large_stdout = "o".repeat(100);
942        let large_stdout_len = large_stdout.len();
943        let mut obj = serde_json::Map::new();
944        obj.insert("stdout".to_string(), json!(large_stdout));
945        obj.insert("stderr".to_string(), json!(large_stderr));
946        obj.insert("exit_code".to_string(), json!(1));
947
948        compact_persisted_result_for_model(
949            &mut obj,
950            "/outputs/call.stdout",
951            large_stdout_len,
952            "auto",
953        );
954
955        let inline_stderr = obj["stderr"].as_str().unwrap();
956        assert!(
957            inline_stderr.len() <= NORMAL_BUDGET + 200,
958            "stderr ({} bytes) must be capped to NORMAL_BUDGET",
959            inline_stderr.len()
960        );
961    }
962
963    #[test]
964    fn test_compact_non_exec_output_field() {
965        // daytona_exec uses "output" instead of "stdout"
966        let large_output = "d".repeat(NORMAL_BUDGET + 1);
967        let large_output_len = large_output.len();
968        let mut obj = serde_json::Map::new();
969        obj.insert("output".to_string(), json!(large_output));
970        obj.insert("exit_code".to_string(), json!(0));
971
972        compact_persisted_result_for_model(
973            &mut obj,
974            "/outputs/call.stdout",
975            large_output_len,
976            "auto",
977        );
978
979        let inline = obj["output"].as_str().unwrap();
980        assert!(
981            inline.len() <= AUTO_SUCCESS_BUDGET + 200,
982            "output field must be compacted"
983        );
984        assert!(inline.contains("full output saved to"));
985    }
986
987    #[test]
988    fn test_compact_no_file_store_preserves_current_behavior() {
989        // When there's no stdout field (unusual result shape), nothing is corrupted.
990        let mut obj = serde_json::Map::new();
991        obj.insert("result".to_string(), json!("ok"));
992        obj.insert("exit_code".to_string(), json!(0));
993
994        compact_persisted_result_for_model(&mut obj, "/outputs/call.stdout", 0, "auto");
995
996        // Non-exec result shape untouched
997        assert_eq!(obj.get("stdout"), None);
998        assert_eq!(obj.get("output"), None);
999        assert_eq!(obj["result"].as_str(), Some("ok"));
1000    }
1001
1002    #[test]
1003    fn test_compact_stderr_inline_caps_stderr() {
1004        // Exercises the compact_stderr_inline helper directly, which is also called
1005        // in after_exec when stderr is persisted but stdout is not.
1006        let large = "e".repeat(NORMAL_BUDGET * 2);
1007        let mut obj = serde_json::Map::new();
1008        obj.insert("stderr".to_string(), json!(large));
1009
1010        compact_stderr_inline(&mut obj, Some(NORMAL_BUDGET));
1011
1012        let inline = obj["stderr"].as_str().unwrap();
1013        assert!(
1014            inline.len() <= NORMAL_BUDGET + 200,
1015            "compact_stderr_inline must cap stderr to budget ({} bytes got)",
1016            inline.len()
1017        );
1018    }
1019}