Skip to main content

vtcode_core/tools/
output_spooler.rs

1//! Tool Output Spooler for Dynamic Context Discovery
2//!
3//! Implements Cursor-style dynamic context discovery by writing large tool outputs
4//! to files instead of truncating them. This allows agents to retrieve the full
5//! output via `unified_file` or search it with `unified_search` when needed.
6//!
7//! ## Design Philosophy
8//!
9//! Instead of truncating large tool responses (which loses data), we:
10//! 1. Write the full output to `.vtcode/context/tool_outputs/{tool}_{timestamp}.txt`
11//! 2. Return a file reference to the agent
12//! 3. Agent can use `unified_file` with offset/limit or `unified_search` to explore
13//!
14//! This is more token-efficient as only necessary data is pulled into context.
15
16use crate::config::constants::tools;
17use anyhow::{Context, Result};
18use serde::{Deserialize, Serialize};
19use serde_json::{Value, json};
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22use tokio::fs;
23use tokio::sync::RwLock;
24use tracing::{debug, info};
25use vtcode_commons::preview::{condense_text_bytes, tail_preview_text};
26use vtcode_commons::serde_helpers::json_to_string_pretty;
27
28/// Default threshold for spooling tool output to files (8KB).
29/// Keep this aligned with `DynamicContextConfig::default().tool_output_threshold`
30/// so invalid workspace config falls back to the same runtime behavior.
31pub const DEFAULT_SPOOL_THRESHOLD_BYTES: usize = 8_192;
32
33const CONDENSE_HEAD_BYTES: usize = 8_000;
34const CONDENSE_TAIL_BYTES: usize = 4_000;
35const PTY_PREVIEW_TAIL_BYTES: usize = 2_500;
36const PTY_PREVIEW_MAX_LINES: usize = 40;
37
38fn is_command_session_tool_name(tool_name: &str) -> bool {
39    crate::tools::tool_intent::canonical_unified_exec_tool_name(tool_name).is_some()
40}
41
42fn condense_content(content: &str) -> String {
43    condense_text_bytes(content, CONDENSE_HEAD_BYTES, CONDENSE_TAIL_BYTES)
44}
45
46fn tail_preview_content(content: &str, tail_bytes: usize, max_lines: usize) -> String {
47    tail_preview_text(content, tail_bytes, max_lines)
48}
49
50/// Configuration for the output spooler
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SpoolerConfig {
53    /// Enable spooling large outputs to files
54    #[serde(default = "default_enabled")]
55    pub enabled: bool,
56
57    /// Threshold in bytes above which outputs are spooled to files
58    #[serde(default = "default_threshold")]
59    pub threshold_bytes: usize,
60
61    /// Maximum number of spooled files to keep
62    #[serde(default = "default_max_files")]
63    pub max_files: usize,
64
65    /// Maximum age in seconds before cleanup removes a spooled file
66    #[serde(default = "default_max_age_secs")]
67    pub max_age_secs: u64,
68
69    /// Whether to include file reference in truncated output
70    #[serde(default = "default_include_reference")]
71    pub include_file_reference: bool,
72}
73
74fn default_enabled() -> bool {
75    true
76}
77
78fn default_threshold() -> usize {
79    DEFAULT_SPOOL_THRESHOLD_BYTES
80}
81
82fn default_max_files() -> usize {
83    100
84}
85
86fn default_max_age_secs() -> u64 {
87    3600
88}
89
90fn default_include_reference() -> bool {
91    true
92}
93
94impl Default for SpoolerConfig {
95    fn default() -> Self {
96        Self {
97            enabled: true,
98            threshold_bytes: DEFAULT_SPOOL_THRESHOLD_BYTES,
99            max_files: 100,
100            max_age_secs: default_max_age_secs(),
101            include_file_reference: true,
102        }
103    }
104}
105
106/// Result of spooling a tool output
107#[derive(Debug, Clone)]
108pub struct SpoolResult {
109    /// Path to the spooled file (relative to workspace)
110    pub file_path: PathBuf,
111    /// Original size in bytes
112    pub original_bytes: usize,
113    /// Full content written to the spool file
114    pub content: String,
115}
116
117/// Tool Output Spooler for writing large outputs to files
118pub struct ToolOutputSpooler {
119    /// Workspace root directory
120    workspace_root: PathBuf,
121    /// Output directory for spooled files
122    output_dir: PathBuf,
123    /// Configuration
124    config: SpoolerConfig,
125    /// Track spooled files for cleanup
126    spooled_files: Arc<RwLock<Vec<PathBuf>>>,
127}
128
129impl ToolOutputSpooler {
130    /// Create a new spooler for the given workspace
131    pub fn new(workspace_root: &Path) -> Self {
132        Self::with_config(workspace_root, SpoolerConfig::default())
133    }
134
135    /// Create a new spooler with custom configuration
136    pub fn with_config(workspace_root: &Path, config: SpoolerConfig) -> Self {
137        let output_dir = workspace_root
138            .join(".vtcode")
139            .join("context")
140            .join("tool_outputs");
141
142        Self {
143            workspace_root: workspace_root.to_path_buf(),
144            output_dir,
145            config,
146            spooled_files: Arc::new(RwLock::new(Vec::new())),
147        }
148    }
149
150    /// Check if a value should be spooled based on size
151    pub fn should_spool(&self, value: &Value) -> bool {
152        if !self.config.enabled {
153            return false;
154        }
155        if value
156            .get("no_spool")
157            .and_then(|v| v.as_bool())
158            .unwrap_or(false)
159        {
160            return false;
161        }
162        self.estimate_size(value) > self.config.threshold_bytes
163    }
164
165    fn estimate_size(&self, value: &Value) -> usize {
166        if let Some(s) = value.get("raw_output").and_then(|v| v.as_str()) {
167            return s.len();
168        }
169        if let Some(s) = value.get("content").and_then(|v| v.as_str()) {
170            return s.len();
171        }
172        if let Some(s) = value.get("output").and_then(|v| v.as_str()) {
173            return s.len();
174        }
175        if let Some(s) = value.as_str() {
176            return s.len();
177        }
178        value.to_string().len()
179    }
180
181    /// Spool a tool output to a file and return a reference
182    pub async fn spool_output(
183        &self,
184        tool_name: &str,
185        value: &Value,
186        is_mcp: bool,
187    ) -> Result<SpoolResult> {
188        // Ensure output directory exists
189        fs::create_dir_all(&self.output_dir)
190            .await
191            .with_context(|| {
192                format!(
193                    "Failed to create tool output directory: {}",
194                    self.output_dir.display()
195                )
196            })?;
197
198        // Generate unique filename
199        let timestamp = std::time::SystemTime::now()
200            .duration_since(std::time::UNIX_EPOCH)
201            .unwrap_or_default()
202            .as_micros();
203        let filename = format!("{}_{}.txt", sanitize_tool_name(tool_name), timestamp);
204        let file_path = self.output_dir.join(&filename);
205
206        // For read_file/unified_file and PTY-related tools, extract raw content so the spooled file is directly usable
207        // This allows grep_file to work on the spooled output and makes reading more intuitive
208        let content = if (tool_name == tools::READ_FILE || tool_name == tools::UNIFIED_FILE)
209            && !is_mcp
210        {
211            if let Some(raw_content) = value.get("content").and_then(|v| v.as_str()) {
212                raw_content.to_string()
213            } else if let Some(json_str) = value.as_str() {
214                // Edge case: value might be a JSON string that needs parsing
215                if let Ok(parsed) = serde_json::from_str::<Value>(json_str) {
216                    if let Some(raw_content) = parsed.get("content").and_then(|v| v.as_str()) {
217                        debug!(
218                            tool = tool_name,
219                            "read_file spool: recovered content from double-serialized JSON string"
220                        );
221                        raw_content.to_string()
222                    } else {
223                        json_str.to_string()
224                    }
225                } else {
226                    json_str.to_string()
227                }
228            } else {
229                // Fallback to JSON serialization if no content field
230                debug!(
231                    tool = tool_name,
232                    has_content = value.get("content").is_some(),
233                    "read_file spool: could not extract content as string; falling back to JSON"
234                );
235                json_to_string_pretty(value)
236            }
237        } else if is_command_session_tool_name(tool_name) && !is_mcp {
238            // For command-session tools, including unified_exec and legacy PTY helpers,
239            // extract the actual command output from the "output" field.
240            // This ensures the spooled file contains the raw command output, not the JSON wrapper.
241            //
242            // Handle two cases:
243            // 1. value is an object with "output" field (normal case)
244            // 2. value is a string containing JSON (edge case: double-serialized)
245            if let Some(output_content) = value.get("raw_output").and_then(|v| v.as_str()) {
246                output_content.to_string()
247            } else if let Some(output_content) = value.get("output").and_then(|v| v.as_str()) {
248                output_content.to_string()
249            } else if let Some(json_str) = value.as_str() {
250                // Edge case: value might be a JSON string that needs parsing
251                // This can happen if the value was serialized somewhere in the pipeline
252                if let Ok(parsed) = serde_json::from_str::<Value>(json_str) {
253                    if let Some(output_content) = parsed.get("raw_output").and_then(|v| v.as_str())
254                    {
255                        debug!(
256                            tool = tool_name,
257                            "PTY spool: recovered raw_output from double-serialized JSON string"
258                        );
259                        output_content.to_string()
260                    } else if let Some(output_content) =
261                        parsed.get("output").and_then(|v| v.as_str())
262                    {
263                        debug!(
264                            tool = tool_name,
265                            "PTY spool: recovered output from double-serialized JSON string"
266                        );
267                        output_content.to_string()
268                    } else {
269                        // Parsed but no output field - use the parsed value's stdout if available
270                        if let Some(stdout) = parsed.get("stdout").and_then(|v| v.as_str()) {
271                            stdout.to_string()
272                        } else {
273                            json_str.to_string()
274                        }
275                    }
276                } else {
277                    // Not valid JSON - use the string as-is
278                    json_str.to_string()
279                }
280            } else {
281                // Fallback to JSON serialization if no output field
282                debug!(
283                    tool = tool_name,
284                    has_output = value.get("output").is_some(),
285                    output_type = ?value.get("output").map(|v| match v {
286                        serde_json::Value::Null => "null",
287                        serde_json::Value::Bool(_) => "bool",
288                        serde_json::Value::Number(_) => "number",
289                        serde_json::Value::String(_) => "string",
290                        serde_json::Value::Array(_) => "array",
291                        serde_json::Value::Object(_) => "object",
292                    }),
293                    "PTY spool: could not extract output as string; falling back to JSON"
294                );
295                json_to_string_pretty(value)
296            }
297        } else if let Some(s) = value.as_str() {
298            s.to_string()
299        } else {
300            json_to_string_pretty(value)
301        };
302
303        // Sanitize content to redact any secrets before writing to disk
304        let sanitized_content = vtcode_commons::sanitizer::redact_secrets(content);
305        let original_bytes = sanitized_content.len();
306
307        fs::write(&file_path, &sanitized_content)
308            .await
309            .with_context(|| format!("Failed to write tool output to: {}", file_path.display()))?;
310
311        {
312            let mut files = self.spooled_files.write().await;
313            files.push(file_path.clone());
314
315            if files.len() > self.config.max_files {
316                let old_file = files.remove(0);
317                let _ = fs::remove_file(&old_file).await;
318            }
319        }
320
321        let relative_path = file_path
322            .strip_prefix(&self.workspace_root)
323            .unwrap_or(&file_path)
324            .to_path_buf();
325
326        info!(
327            tool = tool_name,
328            bytes = original_bytes,
329            path = %relative_path.display(),
330            is_mcp = is_mcp,
331            "Spooled large tool output to file"
332        );
333
334        Ok(SpoolResult {
335            file_path: relative_path,
336            original_bytes,
337            content: sanitized_content,
338        })
339    }
340
341    /// Process a tool output, spooling if necessary.
342    ///
343    /// Returns the original value if below threshold, or a condensed
344    /// head+tail payload with a `spool_path` reference if spooled.
345    pub async fn process_output(
346        &self,
347        tool_name: &str,
348        value: Value,
349        is_mcp: bool,
350    ) -> Result<Value> {
351        self.process_output_with_force(tool_name, value, is_mcp, false)
352            .await
353    }
354
355    /// Process a tool output, optionally forcing spool behavior.
356    ///
357    /// `force_spool=true` bypasses the size threshold but still respects explicit
358    /// `no_spool=true` in the payload.
359    pub async fn process_output_with_force(
360        &self,
361        tool_name: &str,
362        value: Value,
363        is_mcp: bool,
364        force_spool: bool,
365    ) -> Result<Value> {
366        let no_spool = value
367            .get("no_spool")
368            .and_then(|v| v.as_bool())
369            .unwrap_or(false);
370        if no_spool {
371            return Ok(value);
372        }
373        if !self.config.enabled {
374            return Ok(value);
375        }
376        if !force_spool && !self.should_spool(&value) {
377            return Ok(value);
378        }
379
380        let spool_result = self.spool_output(tool_name, &value, is_mcp).await?;
381        let condensed = if is_command_session_tool_name(tool_name) {
382            tail_preview_content(
383                &spool_result.content,
384                PTY_PREVIEW_TAIL_BYTES,
385                PTY_PREVIEW_MAX_LINES,
386            )
387        } else {
388            condense_content(&spool_result.content)
389        };
390        let spool_path = spool_result.file_path.to_string_lossy().to_string();
391
392        let mut response = match value {
393            Value::Object(map) => Value::Object(map),
394            _ => json!({}),
395        };
396        let is_pty_tool = is_command_session_tool_name(tool_name);
397        let use_output_field = is_pty_tool
398            || response
399                .get("output")
400                .and_then(|v| v.as_str())
401                .is_some_and(|s| !s.is_empty());
402        let source_path = if tool_name == tools::READ_FILE || tool_name == tools::UNIFIED_FILE {
403            response
404                .get("path")
405                .and_then(|v| v.as_str())
406                .map(String::from)
407        } else {
408            None
409        };
410        let stderr_preview = response
411            .get("stderr")
412            .and_then(|v| v.as_str())
413            .map(|s| vtcode_commons::formatting::truncate_byte_budget(s, 500, "... (truncated)"));
414
415        if let Some(obj) = response.as_object_mut() {
416            obj.remove("stdout");
417            obj.remove("follow_up_prompt");
418            obj.remove("spooled_to_file");
419            obj.remove("raw_output");
420
421            // Replace only the heavy stream field with condensed preview.
422            if use_output_field {
423                obj.insert("output".to_string(), json!(condensed));
424            } else {
425                obj.insert("content".to_string(), json!(condensed));
426            }
427
428            obj.insert("spool_path".to_string(), json!(spool_path));
429
430            if let Some(src) = source_path {
431                obj.entry("source_path".to_string())
432                    .or_insert_with(|| json!(src));
433            }
434            if let Some(stderr) = stderr_preview {
435                obj.insert("stderr_preview".to_string(), json!(stderr));
436            }
437        }
438
439        Ok(response)
440    }
441
442    /// Clean up old spooled files
443    pub async fn cleanup_old_files(&self) -> Result<usize> {
444        if !self.output_dir.exists() {
445            return Ok(0);
446        }
447
448        let now = std::time::SystemTime::now();
449        let mut removed = 0;
450
451        let mut entries = fs::read_dir(&self.output_dir).await?;
452        while let Some(entry) = entries.next_entry().await? {
453            let path = entry.path();
454            if let Ok(metadata) = entry.metadata().await
455                && let Ok(modified) = metadata.modified()
456                && let Ok(age) = now.duration_since(modified)
457                && age.as_secs() > self.config.max_age_secs
458                && fs::remove_file(&path).await.is_ok()
459            {
460                removed += 1;
461                debug!(path = %path.display(), "Removed old spooled file");
462            }
463        }
464
465        if removed > 0 {
466            info!(count = removed, "Cleaned up old spooled tool output files");
467        }
468
469        Ok(removed)
470    }
471
472    /// Get the output directory path
473    pub fn output_dir(&self) -> &Path {
474        &self.output_dir
475    }
476
477    /// Get current configuration
478    pub fn config(&self) -> &SpoolerConfig {
479        &self.config
480    }
481
482    /// Update configuration
483    pub fn set_config(&mut self, config: SpoolerConfig) {
484        self.config = config;
485    }
486
487    /// List currently spooled files
488    pub async fn list_spooled_files(&self) -> Vec<PathBuf> {
489        self.spooled_files.read().await.clone()
490    }
491}
492
493/// Sanitize tool name for use in filename
494fn sanitize_tool_name(name: &str) -> String {
495    name.chars()
496        .map(|c| {
497            if c.is_alphanumeric() || c == '_' {
498                c
499            } else {
500                '_'
501            }
502        })
503        .collect()
504}
505
506/// Extension trait for integrating spooler with tool results
507pub trait SpoolableOutput {
508    /// Check if this output should be spooled
509    fn should_spool(&self, threshold_bytes: usize) -> bool;
510
511    /// Get the byte size of this output
512    fn byte_size(&self) -> usize;
513}
514
515impl SpoolableOutput for Value {
516    fn should_spool(&self, threshold_bytes: usize) -> bool {
517        self.to_string().len() > threshold_bytes
518    }
519
520    fn byte_size(&self) -> usize {
521        self.to_string().len()
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use tempfile::tempdir;
529
530    #[tokio::test]
531    async fn test_spooler_creation() {
532        let temp = tempdir().unwrap();
533        let spooler = ToolOutputSpooler::new(temp.path());
534
535        assert!(spooler.config.enabled);
536        assert_eq!(
537            spooler.config.threshold_bytes,
538            DEFAULT_SPOOL_THRESHOLD_BYTES
539        );
540        assert_eq!(spooler.config.max_age_secs, default_max_age_secs());
541    }
542
543    #[tokio::test]
544    async fn test_should_spool_small_value() {
545        let temp = tempdir().unwrap();
546        let spooler = ToolOutputSpooler::new(temp.path());
547
548        let small_value = json!({"result": "ok"});
549        assert!(!spooler.should_spool(&small_value));
550    }
551
552    #[tokio::test]
553    async fn test_should_spool_large_value() {
554        let temp = tempdir().unwrap();
555        let config = SpoolerConfig {
556            threshold_bytes: 100,
557            ..Default::default()
558        }; // Low threshold for testing
559        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
560
561        let large_content = "x".repeat(200);
562        let large_value = json!({"content": large_content});
563        assert!(spooler.should_spool(&large_value));
564    }
565
566    #[tokio::test]
567    async fn test_should_not_spool_when_disabled() {
568        let temp = tempdir().unwrap();
569        let config = SpoolerConfig {
570            threshold_bytes: 100,
571            ..Default::default()
572        }; // Low threshold for testing
573        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
574
575        let large_content = "x".repeat(200);
576        let large_value = json!({"output": large_content, "no_spool": true});
577        assert!(!spooler.should_spool(&large_value));
578    }
579
580    #[tokio::test]
581    async fn test_spool_output() {
582        let temp = tempdir().unwrap();
583        let config = SpoolerConfig {
584            threshold_bytes: 50,
585            ..Default::default()
586        };
587        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
588
589        let content = "Line 1\nLine 2\nLine 3\n".repeat(10);
590        let value = json!({"output": content});
591
592        let result = spooler
593            .spool_output("test_tool", &value, false)
594            .await
595            .unwrap();
596
597        assert!(result.file_path.to_string_lossy().contains("test_tool"));
598        assert!(result.original_bytes > 0);
599
600        // Verify file was created
601        let full_path = temp.path().join(&result.file_path);
602        assert!(full_path.exists());
603    }
604
605    #[tokio::test]
606    async fn test_process_output_small() {
607        let temp = tempdir().unwrap();
608        let spooler = ToolOutputSpooler::new(temp.path());
609
610        let small_value = json!({"result": "ok"});
611        let result = spooler
612            .process_output("test", small_value.clone(), false)
613            .await
614            .unwrap();
615
616        // Should return original value unchanged
617        assert_eq!(result, small_value);
618    }
619
620    #[tokio::test]
621    async fn test_process_output_large() {
622        let temp = tempdir().unwrap();
623        let config = SpoolerConfig {
624            threshold_bytes: 50,
625            ..Default::default()
626        };
627        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
628
629        let large_value = json!({"content": "x".repeat(200)});
630        let result = spooler
631            .process_output("test", large_value, false)
632            .await
633            .unwrap();
634
635        assert!(result.get("spool_path").is_some());
636        assert!(result.get("spooled_to_file").is_none());
637        assert!(result.get("content").is_some());
638        assert!(result.get("spool_path").is_some());
639        assert!(result.get("file_path").is_none());
640        assert!(result.get("truncated").is_none());
641        assert!(result.get("omitted_bytes").is_none());
642    }
643
644    #[test]
645    fn test_sanitize_tool_name() {
646        assert_eq!(sanitize_tool_name("read_file"), "read_file");
647        assert_eq!(sanitize_tool_name("mcp/fetch"), "mcp_fetch");
648        assert_eq!(sanitize_tool_name("tool-name"), "tool_name");
649    }
650
651    #[tokio::test]
652    async fn test_read_file_spools_raw_content() {
653        let temp = tempdir().unwrap();
654        let config = SpoolerConfig {
655            threshold_bytes: 50,
656            ..Default::default()
657        };
658        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
659
660        let file_content = "fn main() {\n    println!(\"Hello, world!\");\n}\n// More code here...";
661
662        // Simulate a read_file response with content field
663        let read_file_response = json!({
664            "success": true,
665            "content": file_content,
666            "path": "test.rs"
667        });
668
669        let result = spooler
670            .process_output("read_file", read_file_response, false)
671            .await
672            .unwrap();
673
674        // Should include source_path for read_file
675        let source_path = result.get("source_path").and_then(|v| v.as_str()).unwrap();
676        assert_eq!(source_path, "test.rs");
677
678        let content_field = result.get("content").and_then(|v| v.as_str()).unwrap();
679        assert!(content_field.contains("fn main()"));
680        assert!(!content_field.contains("\"success\"")); // Should not show JSON structure
681
682        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
683        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
684        assert_eq!(spooled_content, file_content);
685        assert!(!spooled_content.contains("\"success\"")); // Raw content, not JSON
686    }
687
688    #[tokio::test]
689    async fn test_run_pty_cmd_spools_raw_output() {
690        let temp = tempdir().unwrap();
691        let config = SpoolerConfig {
692            threshold_bytes: 50,
693            ..Default::default()
694        };
695        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
696
697        let command_output = "   Compiling vtcode-core v0.68.1\n   Checking vtcode-core v0.68.1\n    Finished dev [unoptimized + debuginfo] target(s)";
698
699        // Simulate a run_pty_cmd response with output field
700        let pty_response = json!({
701            "output": command_output,
702            "exit_code": 0,
703            "wall_time": 1.234,
704            "success": true
705        });
706
707        let result = spooler
708            .process_output("run_pty_cmd", pty_response, false)
709            .await
710            .unwrap();
711
712        // Should return file reference
713        assert!(result.get("spool_path").is_some());
714        assert!(result.get("spooled_to_file").is_none());
715
716        // Verify spooled file contains raw output, not JSON wrapper
717        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
718        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
719        assert_eq!(spooled_content, command_output);
720        assert!(!spooled_content.contains("\"output\""));
721        assert!(!spooled_content.contains("\"exit_code\""));
722    }
723
724    #[tokio::test]
725    async fn test_pty_tools_spool_raw_output() {
726        let temp = tempdir().unwrap();
727        let config = SpoolerConfig {
728            threshold_bytes: 50,
729            ..Default::default()
730        };
731        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
732
733        let command_output = "Some command output text\nwith multiple lines\nfor testing";
734
735        let send_input_response = json!({
736            "output": command_output,
737            "wall_time": 0.123,
738            "session_id": "session123"
739        });
740
741        let result = spooler
742            .process_output("send_pty_input", send_input_response, false)
743            .await
744            .unwrap();
745
746        assert!(result.get("spool_path").is_some());
747        assert!(result.get("spooled_to_file").is_none());
748        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
749        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
750        assert_eq!(spooled_content, command_output);
751        assert!(!spooled_content.contains("\"output\""));
752
753        let read_session_response = json!({
754            "output": command_output,
755            "wall_time": 0.456
756        });
757
758        let result = spooler
759            .process_output("read_pty_session", read_session_response, false)
760            .await
761            .unwrap();
762
763        assert!(result.get("spool_path").is_some());
764        assert!(result.get("spooled_to_file").is_none());
765        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
766        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
767        assert_eq!(spooled_content, command_output);
768        assert!(!spooled_content.contains("\"output\""));
769    }
770
771    #[tokio::test]
772    async fn test_forced_pty_spool_keeps_structured_continuation_metadata() {
773        let temp = tempdir().unwrap();
774        let config = SpoolerConfig {
775            threshold_bytes: 999_999,
776            ..Default::default()
777        }; // ensure only force triggers
778        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
779
780        let output = "x".repeat(10_000);
781        let value = json!({
782            "output": output,
783            "process_id": "run-abc123",
784            "next_continue_args": {
785                "session_id": "run-abc123"
786            },
787            "truncated": true
788        });
789
790        let result = spooler
791            .process_output_with_force("run_pty_cmd", value, false, true)
792            .await
793            .unwrap();
794
795        assert_eq!(
796            result.get("process_id").and_then(|v| v.as_str()),
797            Some("run-abc123")
798        );
799        assert_eq!(
800            result.get("next_continue_args"),
801            Some(&json!({
802                "session_id": "run-abc123"
803            }))
804        );
805        assert!(result.get("follow_up_prompt").is_none());
806        assert_eq!(
807            result.get("truncated").and_then(|v| v.as_bool()),
808            Some(true)
809        );
810        assert!(result.get("spooled_to_file").is_none());
811        assert!(
812            result
813                .get("output")
814                .and_then(|v| v.as_str())
815                .is_some_and(|s| s.contains("bytes omitted"))
816        );
817        assert!(result.get("spool_hint").is_none());
818    }
819
820    #[tokio::test]
821    async fn test_unified_exec_spools_raw_output() {
822        let temp = tempdir().unwrap();
823        let config = SpoolerConfig {
824            threshold_bytes: 50,
825            ..Default::default()
826        };
827        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
828
829        let command_output =
830            "   Compiling vtcode-core v0.68.1\n   Checking vtcode-core v0.68.1\n    Finished dev";
831
832        let unified_exec_response = json!({
833            "output": command_output,
834            "exit_code": 0,
835            "wall_time": 1.234,
836            "success": true
837        });
838
839        let result = spooler
840            .process_output("unified_exec", unified_exec_response, false)
841            .await
842            .unwrap();
843
844        assert!(result.get("spool_path").is_some());
845        assert!(result.get("spooled_to_file").is_none());
846
847        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
848        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
849        assert_eq!(spooled_content, command_output);
850        assert!(!spooled_content.contains("\"output\""));
851        assert!(!spooled_content.contains("\"exit_code\""));
852    }
853
854    #[tokio::test]
855    async fn test_unified_exec_spools_internal_raw_output_over_preview() {
856        let temp = tempdir().unwrap();
857        let config = SpoolerConfig {
858            threshold_bytes: 10,
859            ..Default::default()
860        };
861        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
862
863        let raw_output = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6";
864        let preview_output = "line 1\nline 2\n[Output truncated]";
865        let unified_exec_response = json!({
866            "output": preview_output,
867            "raw_output": raw_output,
868            "truncated": true,
869            "exit_code": 0,
870            "wall_time": 1.234,
871            "success": true
872        });
873
874        let result = spooler
875            .process_output("unified_exec", unified_exec_response, false)
876            .await
877            .unwrap();
878
879        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
880        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
881        assert_eq!(spooled_content, raw_output);
882        assert_ne!(spooled_content, preview_output);
883    }
884
885    #[tokio::test]
886    async fn test_double_serialized_pty_output() {
887        let temp = tempdir().unwrap();
888        let config = SpoolerConfig {
889            threshold_bytes: 50,
890            ..Default::default()
891        };
892        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
893
894        let command_output =
895            "   Compiling vtcode-core v0.68.1\n   Checking vtcode-core v0.68.1\n    Finished dev";
896
897        let inner_json = json!({
898            "output": command_output,
899            "exit_code": 0,
900            "wall_time": 1.234,
901            "success": true
902        });
903        let double_serialized = json!(serde_json::to_string(&inner_json).unwrap());
904
905        let result = spooler
906            .process_output("run_pty_cmd", double_serialized, false)
907            .await
908            .unwrap();
909
910        assert!(result.get("spool_path").is_some());
911        assert!(result.get("spooled_to_file").is_none());
912
913        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
914        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
915        assert_eq!(spooled_content, command_output);
916        assert!(!spooled_content.contains("\"output\""));
917    }
918
919    #[tokio::test]
920    async fn test_bash_and_shell_spool_raw_output() {
921        let temp = tempdir().unwrap();
922        let config = SpoolerConfig {
923            threshold_bytes: 50,
924            ..Default::default()
925        };
926        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
927
928        let command_output = "total 32\ndrwxr-xr-x  10 user  staff   320 Jan  1 12:00 .";
929
930        let bash_response = json!({
931            "output": command_output,
932            "exit_code": 0,
933            "wall_time": 0.1
934        });
935
936        let result = spooler
937            .process_output("bash", bash_response, false)
938            .await
939            .unwrap();
940
941        assert!(result.get("spool_path").is_some());
942        assert!(result.get("spooled_to_file").is_none());
943        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
944        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
945        assert_eq!(spooled_content, command_output);
946
947        let shell_response = json!({
948            "output": command_output,
949            "exit_code": 0,
950            "wall_time": 0.2
951        });
952
953        let result = spooler
954            .process_output("shell", shell_response, false)
955            .await
956            .unwrap();
957
958        assert!(result.get("spool_path").is_some());
959        assert!(result.get("spooled_to_file").is_none());
960        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
961        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
962        assert_eq!(spooled_content, command_output);
963    }
964
965    #[test]
966    fn test_condense_content_short() {
967        let short = "a".repeat(CONDENSE_HEAD_BYTES + CONDENSE_TAIL_BYTES);
968        let result = condense_content(&short);
969        assert_eq!(result, short);
970    }
971
972    #[test]
973    fn test_condense_content_long() {
974        let total = 20_000;
975        let long_content = "a".repeat(total);
976        let result = condense_content(&long_content);
977        assert!(result.contains("bytes omitted"));
978        assert!(result.len() < total);
979        assert!(result.starts_with(&"a".repeat(100)));
980        assert!(result.ends_with(&"a".repeat(100)));
981    }
982
983    #[test]
984    fn test_condense_content_utf8_boundary() {
985        let mut content = "a".repeat(CONDENSE_HEAD_BYTES - 1);
986        content.push('é'); // 2-byte char at boundary
987        content.push_str(&"b".repeat(20_000));
988        let result = condense_content(&content);
989        assert!(result.contains("bytes omitted"));
990        assert!(result.is_char_boundary(0));
991    }
992
993    #[test]
994    fn test_tail_preview_content_shows_only_tail() {
995        let input = (0..200)
996            .map(|i| format!("line-{i}"))
997            .collect::<Vec<_>>()
998            .join("\n");
999        let preview = tail_preview_content(&input, 500, 10);
1000        assert!(preview.contains("bytes omitted"));
1001        assert!(preview.contains("line-199"));
1002        assert!(!preview.contains("line-1\n"));
1003    }
1004
1005    #[tokio::test]
1006    async fn test_estimate_size_content_field() {
1007        let temp = tempdir().unwrap();
1008        let spooler = ToolOutputSpooler::new(temp.path());
1009
1010        let val = json!({"content": "hello world"});
1011        assert_eq!(spooler.estimate_size(&val), 11);
1012    }
1013
1014    #[tokio::test]
1015    async fn test_estimate_size_output_field() {
1016        let temp = tempdir().unwrap();
1017        let spooler = ToolOutputSpooler::new(temp.path());
1018
1019        let val = json!({"output": "some output"});
1020        assert_eq!(spooler.estimate_size(&val), 11);
1021    }
1022
1023    #[tokio::test]
1024    async fn test_estimate_size_string_value() {
1025        let temp = tempdir().unwrap();
1026        let spooler = ToolOutputSpooler::new(temp.path());
1027
1028        let val = json!("raw string");
1029        assert_eq!(spooler.estimate_size(&val), 10);
1030    }
1031
1032    #[tokio::test]
1033    async fn test_estimate_size_fallback() {
1034        let temp = tempdir().unwrap();
1035        let spooler = ToolOutputSpooler::new(temp.path());
1036
1037        let val = json!({"some_key": 42});
1038        assert!(spooler.estimate_size(&val) > 0);
1039    }
1040
1041    #[tokio::test]
1042    async fn test_cleanup_old_files_respects_configured_max_age() {
1043        let temp = tempdir().unwrap();
1044        let config = SpoolerConfig {
1045            threshold_bytes: 1,
1046            max_age_secs: 0,
1047            ..Default::default()
1048        };
1049        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1050        let value = json!({"output": "old output"});
1051
1052        let result = spooler
1053            .spool_output("test_tool", &value, false)
1054            .await
1055            .unwrap();
1056        let full_path = temp.path().join(&result.file_path);
1057        assert!(full_path.exists());
1058
1059        tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
1060
1061        let removed = spooler.cleanup_old_files().await.unwrap();
1062        assert_eq!(removed, 1);
1063        assert!(!full_path.exists());
1064    }
1065}