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 with bounded shell inspection or a focused source query 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. The agent can inspect the file with bounded `exec_command` reads
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::io::Read;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23use tokio::fs;
24use tokio::sync::RwLock;
25use tracing::{debug, info};
26use vtcode_commons::formatting::truncate_byte_budget;
27use vtcode_commons::preview::{
28    condense_text_bytes, excerpt_text_lines_with_limit, format_hidden_lines_summary, tail_preview_text,
29};
30use vtcode_commons::serde_helpers::json_to_string_pretty;
31use vtcode_commons::utils::calculate_sha256;
32
33const TOOL_OUTPUT_DIR: &str = ".vtcode/context/tool_outputs";
34const SPOOL_CREATE_ATTEMPTS: u32 = 16;
35
36/// Default threshold for spooling tool output to files (8KB).
37/// Keep this aligned with `DynamicContextConfig::default().tool_output_threshold`
38/// so invalid workspace config falls back to the same runtime behavior.
39pub const DEFAULT_SPOOL_THRESHOLD_BYTES: usize = 8_192;
40
41const CONDENSE_HEAD_BYTES: usize = 8_000;
42const CONDENSE_TAIL_BYTES: usize = 4_000;
43const INSPECTION_PREVIEW_HEAD_BYTES: usize = 4 * 1024;
44const INSPECTION_PREVIEW_TAIL_BYTES: usize = 2 * 1024;
45const COMMAND_PREVIEW_MAX_BYTES: usize = 6 * 1024;
46const COMMAND_PREVIEW_MAX_LINES: usize = 80;
47const PREVIEW_NOTICE_RESERVE_BYTES: usize = 64;
48
49fn condense_content(content: &str) -> String {
50    condense_text_bytes(content, CONDENSE_HEAD_BYTES, CONDENSE_TAIL_BYTES)
51}
52
53fn render_line_bounded_preview(content: &str, max_lines: usize) -> String {
54    let preview = excerpt_text_lines_with_limit(content, max_lines, max_lines / 2);
55    if preview.hidden_count == 0 {
56        return content.to_string();
57    }
58
59    let mut lines = Vec::with_capacity(preview.head.len() + preview.tail.len() + 1);
60    lines.extend(preview.head.into_iter().map(str::to_string));
61    lines.push(format_hidden_lines_summary(preview.hidden_count));
62    lines.extend(preview.tail.into_iter().map(str::to_string));
63    lines.join("\n")
64}
65
66fn command_preview_byte_limit(max_preview_bytes: Option<usize>) -> usize {
67    max_preview_bytes
68        .unwrap_or(COMMAND_PREVIEW_MAX_BYTES)
69        .min(COMMAND_PREVIEW_MAX_BYTES)
70}
71
72fn inspection_preview_content(content: &str, max_preview_bytes: Option<usize>) -> String {
73    let byte_limit = command_preview_byte_limit(max_preview_bytes);
74    if byte_limit == 0 {
75        return String::new();
76    }
77
78    let content_budget = byte_limit.saturating_sub(PREVIEW_NOTICE_RESERVE_BYTES).max(1);
79    let head_bytes = content_budget.saturating_mul(2).div_ceil(3).min(INSPECTION_PREVIEW_HEAD_BYTES);
80    let tail_bytes = content_budget.saturating_sub(head_bytes).min(INSPECTION_PREVIEW_TAIL_BYTES);
81    let byte_bounded = condense_text_bytes(content, head_bytes, tail_bytes);
82    let line_bounded = render_line_bounded_preview(&byte_bounded, COMMAND_PREVIEW_MAX_LINES);
83    truncate_byte_budget(&line_bounded, byte_limit, "")
84}
85
86fn verification_preview_content(content: &str, max_preview_bytes: Option<usize>) -> String {
87    let byte_limit = command_preview_byte_limit(max_preview_bytes);
88    if byte_limit == 0 {
89        return String::new();
90    }
91
92    let content_budget = byte_limit.saturating_sub(PREVIEW_NOTICE_RESERVE_BYTES).max(1);
93    let preview = tail_preview_text(content, content_budget, COMMAND_PREVIEW_MAX_LINES.saturating_sub(1));
94    truncate_byte_budget(&preview, byte_limit, "")
95}
96
97/// Render the bounded model-facing preview for command-session output already
98/// held in memory by a response producer.
99///
100/// This deliberately accepts content rather than a spool path: consumers must
101/// not reopen spool files while shaping the model response.
102pub(crate) fn command_preview_content(
103    tool_name: &str,
104    response: &Value,
105    content: &str,
106    max_preview_bytes: Option<usize>,
107) -> String {
108    let args = json!({
109        "action": "run",
110        "command": response.get("command").cloned().unwrap_or(Value::Null),
111    });
112    match crate::tools::tool_intent::classify_shell_activity(tool_name, &args) {
113        crate::tools::tool_intent::ShellActivity::Inspection => inspection_preview_content(content, max_preview_bytes),
114        crate::tools::tool_intent::ShellActivity::Verification | crate::tools::tool_intent::ShellActivity::Mutation => {
115            verification_preview_content(content, max_preview_bytes)
116        }
117    }
118}
119
120/// Which retention rules a cleanup pass applies.
121#[derive(Debug, Clone, Copy, Eq, PartialEq)]
122enum SpoolCleanupScope {
123    /// Drop only files older than `max_age_secs`.
124    AgeOnly,
125    /// Age expiry plus the `max_files` count budget.
126    AgeAndCount,
127}
128
129impl SpoolCleanupScope {
130    fn enforces_count_budget(self) -> bool {
131        matches!(self, SpoolCleanupScope::AgeAndCount)
132    }
133}
134
135/// Configuration for the output spooler
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct SpoolerConfig {
138    /// Enable spooling large outputs to files
139    #[serde(default = "default_enabled")]
140    pub enabled: bool,
141
142    /// Threshold in bytes above which outputs are spooled to files
143    #[serde(default = "default_threshold")]
144    pub threshold_bytes: usize,
145
146    /// Maximum number of spooled files to keep
147    #[serde(default = "default_max_files")]
148    pub max_files: usize,
149
150    /// Maximum age in seconds before cleanup removes a spooled file
151    #[serde(default = "default_max_age_secs")]
152    pub max_age_secs: u64,
153
154    /// Whether to include file reference in truncated output
155    #[serde(default = "default_include_reference")]
156    pub include_file_reference: bool,
157}
158
159fn default_enabled() -> bool {
160    true
161}
162
163fn default_threshold() -> usize {
164    DEFAULT_SPOOL_THRESHOLD_BYTES
165}
166
167fn default_max_files() -> usize {
168    100
169}
170
171fn default_max_age_secs() -> u64 {
172    3600
173}
174
175fn default_include_reference() -> bool {
176    true
177}
178
179impl Default for SpoolerConfig {
180    fn default() -> Self {
181        Self {
182            enabled: true,
183            threshold_bytes: DEFAULT_SPOOL_THRESHOLD_BYTES,
184            max_files: 100,
185            max_age_secs: default_max_age_secs(),
186            include_file_reference: true,
187        }
188    }
189}
190
191/// Result of spooling a tool output
192#[derive(Debug, Clone)]
193pub struct SpoolResult {
194    /// Path to the spooled file (relative to workspace)
195    pub file_path: PathBuf,
196    /// Original size in bytes
197    pub original_bytes: usize,
198    /// Exact number of bytes committed to the immutable spool.
199    pub byte_count: u64,
200    /// SHA-256 digest of the committed bytes.
201    pub sha256: String,
202    /// Lifecycle state of this spool reference.
203    pub state: SpoolState,
204    /// Full content written to the spool file
205    pub content: String,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum SpoolState {
210    Pending,
211    Completed,
212    Unknown,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub(crate) struct SpoolIntegrity {
217    pub byte_count: u64,
218    pub line_count: u64,
219    pub sha256: String,
220}
221
222/// Count physical lines across committed chunks, including an unterminated last line.
223#[derive(Default)]
224pub(crate) struct SpoolLineCounter {
225    newline_count: u64,
226    last_byte: Option<u8>,
227}
228
229impl SpoolLineCounter {
230    pub(crate) fn append(&mut self, bytes: &[u8]) {
231        self.newline_count = self
232            .newline_count
233            .saturating_add(bytes.iter().filter(|byte| **byte == b'\n').count() as u64);
234        if let Some(byte) = bytes.last() {
235            self.last_byte = Some(*byte);
236        }
237    }
238
239    pub(crate) fn line_count(&self) -> u64 {
240        self.newline_count
241            .saturating_add(u64::from(self.last_byte.is_some_and(|byte| byte != b'\n')))
242    }
243}
244
245pub(crate) fn encode_digest_hex(digest: impl AsRef<[u8]>) -> String {
246    use std::fmt::Write as _;
247
248    let bytes = digest.as_ref();
249    let mut encoded = String::with_capacity(bytes.len() * 2);
250    for byte in bytes {
251        let _ = write!(encoded, "{byte:02x}");
252    }
253    encoded
254}
255
256/// Validated view of the bounded model-facing portion of a spooled result.
257///
258/// Consumers should use this interface instead of reopening `spool_path` just
259/// to reconstruct data that the spooler already produced.
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub struct SpooledOutputReference<'a> {
262    pub spool_path: &'a str,
263    pub preview: Option<&'a str>,
264    pub original_bytes: Option<u64>,
265    pub sha256: Option<&'a str>,
266    pub state: SpoolState,
267}
268
269impl<'a> SpooledOutputReference<'a> {
270    #[must_use]
271    pub fn recognizes_path(path: &Path) -> bool {
272        is_workspace_tool_output_path(path, true)
273    }
274
275    /// Return whether `path` names a direct child spool file rather than the
276    /// spool directory itself. Read-file and replay consumers require this
277    /// stricter shape; shell search commands may intentionally target the
278    /// directory through [`Self::recognizes_path`].
279    #[must_use]
280    pub fn recognizes_file_path(path: &Path) -> bool {
281        is_workspace_tool_output_path(path, false)
282    }
283
284    #[must_use]
285    pub fn from_value(value: &'a Value) -> Option<Self> {
286        let spool_path = value
287            .get("spool_path")
288            .and_then(Value::as_str)
289            .map(str::trim)
290            .filter(|path| !path.is_empty())?;
291        let preview = value
292            .get("preview")
293            .and_then(Value::as_str)
294            .filter(|preview| !preview.trim().is_empty());
295        let original_bytes = value.get("spooled_bytes").and_then(Value::as_u64);
296        let sha256 = value.get("spool_sha256").and_then(Value::as_str);
297        let state = match value.get("spool_state").and_then(Value::as_str) {
298            Some("pending") => SpoolState::Pending,
299            Some("completed") => SpoolState::Completed,
300            _ if value.get("spool_pending").and_then(Value::as_bool) == Some(true) => SpoolState::Pending,
301            _ if value.get("spool_complete").and_then(Value::as_bool) == Some(true) => SpoolState::Completed,
302            _ => SpoolState::Unknown,
303        };
304        Some(Self { spool_path, preview, original_bytes, sha256, state })
305    }
306
307    /// Read an immutable completed spool after validating its workspace
308    /// location, regular-file identity, byte count, and digest.
309    pub fn read_verified_completed(&self, workspace_root: &Path) -> Result<String> {
310        anyhow::ensure!(self.state == SpoolState::Completed, "spool is not marked completed");
311        let expected_bytes = self.original_bytes.context("completed spool is missing byte count")?;
312        let expected_sha256 = self.sha256.context("completed spool is missing SHA-256 digest")?;
313        anyhow::ensure!(
314            is_workspace_tool_output_path(Path::new(self.spool_path), false),
315            "unrecognized tool output spool path"
316        );
317
318        let canonical_root = vtcode_commons::canonicalize(workspace_root)
319            .with_context(|| format!("canonicalize workspace root {}", workspace_root.display()))?;
320        let path = Path::new(self.spool_path);
321        let relative = if path.is_absolute() {
322            path.strip_prefix(&canonical_root)
323                .context("absolute spool path is outside workspace")?
324        } else {
325            path
326        };
327        anyhow::ensure!(
328            is_workspace_tool_output_path(relative, false),
329            "spool path is outside the tool output directory"
330        );
331
332        let mut file = vtcode_commons::fs::bound_file::open_file_beneath(&canonical_root, relative)
333            .with_context(|| format!("open completed spool {}", relative.display()))?;
334        let metadata = file.metadata().context("read completed spool metadata")?;
335        anyhow::ensure!(metadata.is_file(), "completed spool is not a regular file");
336        anyhow::ensure!(metadata.len() == expected_bytes, "completed spool length mismatch");
337        let capacity = usize::try_from(expected_bytes).context("completed spool is too large to read")?;
338        let mut bytes = Vec::with_capacity(capacity);
339        file.read_to_end(&mut bytes).context("read completed spool")?;
340        anyhow::ensure!(bytes.len() as u64 == expected_bytes, "completed spool changed while reading");
341        anyhow::ensure!(calculate_sha256(&bytes) == expected_sha256, "completed spool digest mismatch");
342        String::from_utf8(bytes).context("completed spool is not valid UTF-8")
343    }
344
345    /// Read a bounded snapshot of a live append-only spool. Pending output is
346    /// explicitly unverified because the producer may append after this read.
347    pub fn read_pending_bounded(&self, workspace_root: &Path, max_bytes: usize) -> Result<String> {
348        anyhow::ensure!(self.state == SpoolState::Pending, "spool is not marked pending");
349        anyhow::ensure!(max_bytes > 0, "pending spool read limit must be positive");
350        anyhow::ensure!(
351            is_workspace_tool_output_path(Path::new(self.spool_path), false),
352            "unrecognized tool output spool path"
353        );
354        let canonical_root = vtcode_commons::canonicalize(workspace_root)
355            .with_context(|| format!("canonicalize workspace root {}", workspace_root.display()))?;
356        let path = Path::new(self.spool_path);
357        let relative = if path.is_absolute() {
358            path.strip_prefix(&canonical_root)
359                .context("absolute spool path is outside workspace")?
360        } else {
361            path
362        };
363        let file = vtcode_commons::fs::bound_file::open_file_beneath(&canonical_root, relative)
364            .with_context(|| format!("open pending spool {}", relative.display()))?;
365        let mut bytes = Vec::with_capacity(max_bytes.min(64 * 1024));
366        file.take(max_bytes as u64)
367            .read_to_end(&mut bytes)
368            .context("read bounded pending spool")?;
369        Ok(decode_pending_snapshot(bytes))
370    }
371}
372
373/// Decode a bounded pending snapshot without turning a byte-boundary split
374/// into a completely missing preview. Pending spools are normally valid UTF-8
375/// because their producers write strings, but a byte cap can end inside the
376/// final code point. Drop only that incomplete trailing sequence; if the
377/// snapshot contains an invalid sequence elsewhere, preserve the readable
378/// portions with the standard replacement character.
379fn decode_pending_snapshot(bytes: Vec<u8>) -> String {
380    match String::from_utf8(bytes) {
381        Ok(text) => text,
382        Err(error) => {
383            let utf8_error = error.utf8_error();
384            let bytes = error.into_bytes();
385            if utf8_error.error_len().is_none() {
386                String::from_utf8_lossy(&bytes[..utf8_error.valid_up_to()]).into_owned()
387            } else {
388                String::from_utf8_lossy(&bytes).into_owned()
389            }
390        }
391    }
392}
393
394fn is_workspace_tool_output_path(path: &Path, allow_directory: bool) -> bool {
395    // Tool arguments can replay a Windows path on Unix (or the reverse), so
396    // recognize both separators while still comparing exact path components.
397    let normalized = path.to_string_lossy().replace('\\', "/");
398    let absolute = normalized.starts_with('/')
399        || (normalized.as_bytes().get(1) == Some(&b':') && normalized.as_bytes().get(2) == Some(&b'/'));
400    let mut components = Vec::new();
401    for component in normalized.split('/') {
402        if component.is_empty() || component == "." {
403            continue;
404        }
405        if component == ".." {
406            return false;
407        }
408        components.push(component);
409    }
410
411    let needle = [".vtcode", "context", "tool_outputs"];
412    if let Some(directory_start) = components.len().checked_sub(needle.len() + 1)
413        && (absolute || directory_start == 0)
414        && components[directory_start..directory_start + needle.len()] == needle
415    {
416        return true;
417    }
418    if !allow_directory {
419        return false;
420    }
421    let Some(directory_start) = components.len().checked_sub(needle.len()) else {
422        return false;
423    };
424    (absolute || directory_start == 0) && components[directory_start..] == needle
425}
426
427/// Fill in the bounded metadata required by a model-facing spool reference.
428///
429/// Command-session spools are written by the session manager rather than this
430/// spooler, so they arrive with the storage marker but without the regular
431/// spooler envelope. Keep that normalization at the spooler boundary so all
432/// reference producers share the same preview and recovery metadata.
433pub(crate) fn ensure_spooled_reference_metadata(value: &mut Value) {
434    let Some(object) = value.as_object_mut() else {
435        return;
436    };
437    let Some(spool_path) = object
438        .get("spool_path")
439        .and_then(Value::as_str)
440        .filter(|path| !path.is_empty())
441        .map(str::to_owned)
442    else {
443        return;
444    };
445
446    let preview_source = ["preview", "output", "raw_output", "stdout", "content"]
447        .into_iter()
448        .find_map(|field| {
449            object
450                .get(field)
451                .and_then(Value::as_str)
452                .filter(|text| !text.is_empty())
453                .map(str::to_owned)
454        });
455    if object
456        .get("preview")
457        .and_then(Value::as_str)
458        .is_none_or(|preview| preview.is_empty())
459        && let Some(preview) = preview_source.as_deref()
460    {
461        object.insert("preview".to_string(), Value::String(preview.to_string()));
462    }
463
464    let spooled_bytes = object
465        .get("spooled_bytes")
466        .and_then(Value::as_u64)
467        .or_else(|| object.get("total_output_bytes").and_then(Value::as_u64))
468        .or_else(|| preview_source.as_ref().map(|preview| preview.len() as u64));
469    if let Some(bytes) = spooled_bytes {
470        object.entry("spooled_bytes").or_insert_with(|| json!(bytes));
471        let extent = object
472            .get("spool_line_count")
473            .and_then(Value::as_u64)
474            .map(|lines| format!(", {lines} lines; EOF at line {lines}"))
475            .unwrap_or_default();
476        object.entry("spool_note").or_insert_with(|| {
477            json!(format!(
478                "Large output ({bytes} bytes{extent}) spooled to `{spool_path}`. Do not re-inline the full file. Use exec_command with sed or rg to inspect only missing sections; avoid overlapping reads."
479            ))
480        });
481    } else {
482        object.entry("spool_note").or_insert_with(|| {
483            json!(format!(
484                "Large output spooled to `{spool_path}`. Use exec_command with cat, sed, or rg to inspect only the sections you need."
485            ))
486        });
487    }
488}
489
490/// How often (in number of spool operations) to run age-based cleanup.
491const CLEANUP_EVERY_N_SPOOLS: u64 = 50;
492
493/// Tool Output Spooler for writing large outputs to files
494pub struct ToolOutputSpooler {
495    /// Workspace root directory
496    workspace_root: PathBuf,
497    /// Output directory for spooled files
498    output_dir: PathBuf,
499    /// Configuration
500    config: SpoolerConfig,
501    /// Track spooled files for cleanup
502    spooled_files: Arc<RwLock<Vec<PathBuf>>>,
503    /// Pinned spool files that must survive cleanup/eviction because a
504    /// blocked turn handoff references them. Bounded: pins are cleared when
505    /// the owning session resolves its blocker or on explicit release.
506    pinned_files: Arc<RwLock<std::collections::HashSet<PathBuf>>>,
507    /// Counter for throttling periodic cleanup
508    spool_count: std::sync::atomic::AtomicU64,
509}
510
511impl ToolOutputSpooler {
512    /// Create a new spooler for the given workspace
513    pub fn new(workspace_root: &Path) -> Self {
514        Self::with_config(workspace_root, SpoolerConfig::default())
515    }
516
517    /// Create a new spooler with custom configuration
518    pub fn with_config(workspace_root: &Path, config: SpoolerConfig) -> Self {
519        let output_dir = workspace_root.join(TOOL_OUTPUT_DIR);
520
521        let max_files = config.max_files;
522        Self {
523            workspace_root: workspace_root.to_path_buf(),
524            output_dir,
525            config,
526            spooled_files: Arc::new(RwLock::new(Vec::with_capacity(max_files))),
527            pinned_files: Arc::new(RwLock::new(std::collections::HashSet::new())),
528            spool_count: std::sync::atomic::AtomicU64::new(0),
529        }
530    }
531
532    /// Check if a value should be spooled based on size
533    pub fn should_spool(&self, value: &Value) -> bool {
534        if !self.config.enabled {
535            return false;
536        }
537        if value.get("no_spool").and_then(|v| v.as_bool()).unwrap_or(false) {
538            return false;
539        }
540        self.estimate_size(value) > self.config.threshold_bytes
541    }
542
543    fn estimate_size(&self, value: &Value) -> usize {
544        if let Some(s) = value.get("raw_output").and_then(|v| v.as_str()) {
545            return s.len();
546        }
547        if let Some(s) = value.get("content").and_then(|v| v.as_str()) {
548            return s.len();
549        }
550        if let Some(s) = value.get("output").and_then(|v| v.as_str()) {
551            return s.len();
552        }
553        if let Some(s) = value.as_str() {
554            return s.len();
555        }
556        value.to_string().len()
557    }
558
559    /// Spool a tool output to a file and return a reference
560    pub async fn spool_output(&self, tool_name: &str, value: &Value, is_mcp: bool) -> Result<SpoolResult> {
561        // Generate a collision-resistant filename. Creation below is
562        // exclusive, so an existing immutable spool can never be truncated.
563        let timestamp = std::time::SystemTime::now()
564            .duration_since(std::time::UNIX_EPOCH)
565            .unwrap_or_default()
566            .as_micros();
567
568        // For file and command-session tools, extract raw content so the spooled file is directly usable.
569        // This allows grep_file to work on the spooled output and makes reading more intuitive
570        let content = if (tool_name == tools::READ_FILE || tool_name == tools::UNIFIED_FILE) && !is_mcp {
571            if let Some(raw_content) = value.get("content").and_then(|v| v.as_str()) {
572                raw_content.to_string()
573            } else if let Some(json_str) = value.as_str() {
574                // Edge case: value might be a JSON string that needs parsing
575                if let Ok(parsed) = serde_json::from_str::<Value>(json_str) {
576                    if let Some(raw_content) = parsed.get("content").and_then(|v| v.as_str()) {
577                        debug!(
578                            tool = tool_name,
579                            "read_file spool: recovered content from double-serialized JSON string"
580                        );
581                        raw_content.to_string()
582                    } else {
583                        json_str.to_string()
584                    }
585                } else {
586                    json_str.to_string()
587                }
588            } else {
589                // Fallback to JSON serialization if no content field
590                debug!(
591                    tool = tool_name,
592                    has_content = value.get("content").is_some(),
593                    "read_file spool: could not extract content as string; falling back to JSON"
594                );
595                json_to_string_pretty(value)
596            }
597        } else if crate::tools::tool_intent::is_command_session_tool(tool_name) && !is_mcp {
598            // For command-session tools and legacy PTY helpers,
599            // extract the actual command output from the "output" field.
600            // This ensures the spooled file contains the raw command output, not the JSON wrapper.
601            //
602            // Handle two cases:
603            // 1. value is an object with "output" field (normal case)
604            // 2. value is a string containing JSON (edge case: double-serialized)
605            if let Some(output_content) = value.get("raw_output").and_then(|v| v.as_str()) {
606                output_content.to_string()
607            } else if let Some(output_content) = value.get("output").and_then(|v| v.as_str()) {
608                output_content.to_string()
609            } else if let Some(json_str) = value.as_str() {
610                // Edge case: value might be a JSON string that needs parsing
611                // This can happen if the value was serialized somewhere in the pipeline
612                if let Ok(parsed) = serde_json::from_str::<Value>(json_str) {
613                    if let Some(output_content) = parsed.get("raw_output").and_then(|v| v.as_str()) {
614                        debug!(tool = tool_name, "PTY spool: recovered raw_output from double-serialized JSON string");
615                        output_content.to_string()
616                    } else if let Some(output_content) = parsed.get("output").and_then(|v| v.as_str()) {
617                        debug!(tool = tool_name, "PTY spool: recovered output from double-serialized JSON string");
618                        output_content.to_string()
619                    } else {
620                        // Parsed but no output field - use the parsed value's stdout if available
621                        if let Some(stdout) = parsed.get("stdout").and_then(|v| v.as_str()) {
622                            stdout.to_string()
623                        } else {
624                            json_str.to_string()
625                        }
626                    }
627                } else {
628                    // Not valid JSON - use the string as-is
629                    json_str.to_string()
630                }
631            } else {
632                // Fallback to JSON serialization if no output field
633                debug!(
634                    tool = tool_name,
635                    has_output = value.get("output").is_some(),
636                    output_type = ?value.get("output").map(|v| match v {
637                        serde_json::Value::Null => "null",
638                        serde_json::Value::Bool(_) => "bool",
639                        serde_json::Value::Number(_) => "number",
640                        serde_json::Value::String(_) => "string",
641                        serde_json::Value::Array(_) => "array",
642                        serde_json::Value::Object(_) => "object",
643                    }),
644                    "PTY spool: could not extract output as string; falling back to JSON"
645                );
646                json_to_string_pretty(value)
647            }
648        } else if let Some(s) = value.as_str() {
649            s.to_string()
650        } else {
651            json_to_string_pretty(value)
652        };
653
654        // Sanitize content to redact any secrets before writing to disk
655        let sanitized_content = vtcode_commons::sanitizer::redact_secrets(content);
656        let original_bytes = sanitized_content.len();
657        let byte_count = original_bytes as u64;
658        let sha256 = calculate_sha256(sanitized_content.as_bytes());
659        let canonical_root = vtcode_commons::canonicalize(&self.workspace_root)
660            .with_context(|| format!("canonicalize spool workspace {}", self.workspace_root.display()))?;
661        let sanitized_name = sanitize_tool_name(tool_name);
662        let bytes = sanitized_content.as_bytes().to_vec();
663        let (file_path, relative_path) = tokio::task::spawn_blocking(move || -> Result<(PathBuf, PathBuf)> {
664            let output_dir = Path::new(TOOL_OUTPUT_DIR);
665            vtcode_commons::fs::bound_file::ensure_directory_beneath(&canonical_root, output_dir)
666                .context("create bound tool output directory")?;
667            for collision in 0..SPOOL_CREATE_ATTEMPTS {
668                let suffix = if collision == 0 {
669                    String::new()
670                } else {
671                    format!("_{collision}")
672                };
673                let filename = format!("{sanitized_name}_{timestamp}{suffix}.txt");
674                let relative = output_dir.join(filename);
675                match vtcode_commons::fs::bound_file::write_file_beneath(&canonical_root, &relative, &bytes) {
676                    Ok(()) => return Ok((canonical_root.join(&relative), relative)),
677                    Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
678                    Err(error) => return Err(error).context("write immutable tool output spool"),
679                }
680            }
681            anyhow::bail!("tool output spool filename collisions exceeded {SPOOL_CREATE_ATTEMPTS} attempts")
682        })
683        .await
684        .context("join tool output spool writer")??;
685
686        {
687            let mut files = self.spooled_files.write().await;
688            files.push(file_path.clone());
689
690            if files.len() > self.config.max_files {
691                let pinned = self.pinned_files.read().await;
692                if let Some(evict_idx) = files.iter().position(|candidate| !pinned.contains(candidate)) {
693                    let old_file = files.remove(evict_idx);
694                    drop(pinned);
695                    let _ = fs::remove_file(&old_file).await;
696                }
697            }
698        }
699
700        info!(
701            tool = tool_name,
702            bytes = original_bytes,
703            path = %relative_path.display(),
704            is_mcp = is_mcp,
705            "Spooled large tool output to file"
706        );
707
708        Ok(SpoolResult {
709            file_path: relative_path,
710            original_bytes,
711            byte_count,
712            sha256,
713            state: SpoolState::Completed,
714            content: sanitized_content,
715        })
716    }
717
718    /// Process a tool output, spooling if necessary.
719    ///
720    /// Returns the original value if below threshold, or a condensed
721    /// head+tail payload with a `spool_path` reference if spooled.
722    /// Triggers periodic age-based cleanup of old spooled files.
723    pub async fn process_output(&self, tool_name: &str, value: Value, is_mcp: bool) -> Result<Value> {
724        self.process_output_with_options(tool_name, value, is_mcp, false, None).await
725    }
726
727    /// Process a tool output, optionally forcing spool behavior.
728    ///
729    /// `force_spool=true` bypasses the size threshold but still respects explicit
730    /// `no_spool=true` in the payload.
731    pub async fn process_output_with_force(
732        &self,
733        tool_name: &str,
734        value: Value,
735        is_mcp: bool,
736        force_spool: bool,
737    ) -> Result<Value> {
738        self.process_output_with_options(tool_name, value, is_mcp, force_spool, None)
739            .await
740    }
741
742    pub(crate) async fn process_output_with_preview_limit(
743        &self,
744        tool_name: &str,
745        value: Value,
746        is_mcp: bool,
747        force_spool: bool,
748        max_preview_bytes: usize,
749    ) -> Result<Value> {
750        self.process_output_with_options(tool_name, value, is_mcp, force_spool, Some(max_preview_bytes))
751            .await
752    }
753
754    async fn process_output_with_options(
755        &self,
756        tool_name: &str,
757        value: Value,
758        is_mcp: bool,
759        force_spool: bool,
760        max_preview_bytes: Option<usize>,
761    ) -> Result<Value> {
762        let no_spool = value.get("no_spool").and_then(|v| v.as_bool()).unwrap_or(false);
763        if no_spool {
764            return Ok(value);
765        }
766        if !self.config.enabled {
767            return Ok(value);
768        }
769        if !force_spool && !self.should_spool(&value) {
770            return Ok(value);
771        }
772
773        let spool_result = self.spool_output(tool_name, &value, is_mcp).await?;
774
775        // Periodic age-based cleanup: run every CLEANUP_EVERY_N_SPOOLS spool operations.
776        // This ensures stale files are removed without adding overhead on every call.
777        let count = self.spool_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
778        if count.is_multiple_of(CLEANUP_EVERY_N_SPOOLS) {
779            if let Err(e) = self.cleanup_old_files().await {
780                debug!(error = %e, "Periodic spool cleanup failed");
781            }
782        }
783        let condensed = if crate::tools::tool_intent::is_command_session_tool(tool_name) {
784            command_preview_content(tool_name, &value, &spool_result.content, max_preview_bytes)
785        } else {
786            let condensed = condense_content(&spool_result.content);
787            max_preview_bytes
788                .map(|limit| truncate_byte_budget(&condensed, limit, ""))
789                .unwrap_or(condensed)
790        };
791        let spool_path = spool_result.file_path.to_string_lossy().to_string();
792
793        let mut response = match value {
794            Value::Object(map) => Value::Object(map),
795            _ => json!({}),
796        };
797        let is_pty_tool = crate::tools::tool_intent::is_command_session_tool(tool_name);
798        let use_output_field =
799            is_pty_tool || response.get("output").and_then(|v| v.as_str()).is_some_and(|s| !s.is_empty());
800        let source_path = if tool_name == tools::READ_FILE || tool_name == tools::UNIFIED_FILE {
801            response.get("path").and_then(|v| v.as_str()).map(String::from)
802        } else {
803            None
804        };
805        let stderr_preview = response
806            .get("stderr")
807            .and_then(|v| v.as_str())
808            .map(|s| truncate_byte_budget(s, 500, "... (truncated)"));
809
810        if let Some(obj) = response.as_object_mut() {
811            obj.remove("stdout");
812            obj.remove("follow_up_prompt");
813            obj.remove("spooled_to_file");
814            obj.remove("raw_output");
815            obj.insert("preview".to_string(), json!(condensed.clone()));
816
817            // Replace only the heavy stream field with condensed preview.
818            if use_output_field {
819                obj.insert("output".to_string(), json!(condensed));
820            } else {
821                obj.insert("content".to_string(), json!(condensed));
822            }
823
824            obj.insert("spool_path".to_string(), json!(spool_path));
825            obj.insert("spooled_bytes".to_string(), json!(spool_result.original_bytes));
826            obj.insert("spool_sha256".to_string(), json!(spool_result.sha256));
827            obj.insert("spool_state".to_string(), json!("completed"));
828            obj.insert("spool_complete".to_string(), json!(true));
829            let mut line_counter = SpoolLineCounter::default();
830            line_counter.append(spool_result.content.as_bytes());
831            obj.insert("spool_line_count".to_string(), json!(line_counter.line_count()));
832
833            if let Some(src) = source_path {
834                obj.entry("source_path".to_string()).or_insert_with(|| json!(src));
835            }
836            if let Some(stderr) = stderr_preview {
837                obj.insert("stderr_preview".to_string(), json!(stderr));
838            }
839        }
840
841        ensure_spooled_reference_metadata(&mut response);
842        Ok(response)
843    }
844
845    /// Pin spool files referenced by a blocked-turn handoff so age/eviction
846    /// cleanup cannot delete them before the user resumes. Pins are bounded:
847    /// callers must release them when the blocker resolves.
848    pub async fn pin_spool_files(&self, paths: &[PathBuf]) {
849        if paths.is_empty() {
850            return;
851        }
852        let mut pinned = self.pinned_files.write().await;
853        pinned.extend(paths.iter().cloned());
854    }
855
856    /// Release pins for spool files that are no longer needed (e.g. blocker
857    /// resolved). Missing entries are ignored.
858    pub async fn unpin_spool_files(&self, paths: &[PathBuf]) {
859        if paths.is_empty() {
860            return;
861        }
862        let mut pinned = self.pinned_files.write().await;
863        for path in paths {
864            pinned.remove(path);
865        }
866    }
867
868    /// Extract workspace-relative spool paths from model-facing values so a
869    /// blocked-turn handoff can pin exactly the files the next turn needs.
870    pub fn spool_paths_from_value(value: &Value) -> Option<String> {
871        SpooledOutputReference::from_value(value).map(|reference| reference.spool_path.to_string())
872    }
873
874    /// Clean up old spooled files and sync the in-memory tracking list.
875    /// Pinned blocked-turn outputs are never deleted here.
876    ///
877    /// Also enforces [`SpoolerConfig::max_files`] against the on-disk
878    /// directory (not just this instance's tracking vec) so leftovers from
879    /// prior sessions cannot accumulate past the budget.
880    pub async fn cleanup_old_files(&self) -> Result<usize> {
881        self.cleanup_files(SpoolCleanupScope::AgeAndCount).await
882    }
883
884    /// Age-based cleanup only, for startup paths.
885    ///
886    /// The count budget relies on [`Self::pinned_files`], which is per-process:
887    /// a second session starting in the same workspace cannot see a concurrent
888    /// session's pinned or active spools, so enforcing the count budget at
889    /// startup can delete young files the other session still reads from its
890    /// conversation history. Age-expired files are safe to drop anywhere: no
891    /// live session produced them within `max_age_secs`. The count budget
892    /// stays on the in-session periodic path ([`Self::cleanup_old_files`]).
893    pub async fn cleanup_expired_files(&self) -> Result<usize> {
894        self.cleanup_files(SpoolCleanupScope::AgeOnly).await
895    }
896
897    async fn cleanup_files(&self, scope: SpoolCleanupScope) -> Result<usize> {
898        if !fs::try_exists(&self.output_dir).await.unwrap_or(false) {
899            return Ok(0);
900        }
901
902        let now = std::time::SystemTime::now();
903        let mut removed = 0;
904
905        // Collect paths to remove (can't modify vec during filesystem iteration)
906        let mut paths_to_remove = Vec::new();
907        // Age of each surviving candidate so max_files can drop the oldest.
908        let mut survivors: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
909
910        let pinned = self.pinned_files.read().await;
911        let mut entries = fs::read_dir(&self.output_dir).await?;
912        while let Some(entry) = entries.next_entry().await? {
913            let path = entry.path();
914            if pinned.contains(&path) {
915                continue;
916            }
917            if let Ok(metadata) = entry.metadata().await
918                && let Ok(modified) = metadata.modified()
919            {
920                let age = now.duration_since(modified).unwrap_or_default();
921                if age.as_secs() > self.config.max_age_secs {
922                    paths_to_remove.push(path);
923                } else {
924                    survivors.push((path, modified));
925                }
926            }
927        }
928        drop(pinned);
929
930        // Enforce the file-count budget across sessions: drop the oldest
931        // unpinned survivors until at most max_files remain on disk.
932        if scope.enforces_count_budget() && survivors.len() > self.config.max_files {
933            survivors.sort_by_key(|(_, modified)| *modified);
934            let overflow = survivors.len() - self.config.max_files;
935            for (path, _) in survivors.into_iter().take(overflow) {
936                paths_to_remove.push(path);
937            }
938        }
939
940        // Remove files from disk
941        for path in &paths_to_remove {
942            if fs::remove_file(path).await.is_ok() {
943                removed += 1;
944                debug!(path = %path.display(), "Removed old spooled file");
945            }
946        }
947
948        // Sync in-memory tracking: remove entries pointing to deleted files
949        if removed > 0 {
950            let mut files = self.spooled_files.write().await;
951            files.retain(|p| !paths_to_remove.contains(p));
952        }
953
954        if removed > 0 {
955            info!(count = removed, "Cleaned up old spooled tool output files");
956        }
957
958        Ok(removed)
959    }
960
961    /// Get the output directory path
962    pub fn output_dir(&self) -> &Path {
963        &self.output_dir
964    }
965
966    /// Get current configuration
967    pub fn config(&self) -> &SpoolerConfig {
968        &self.config
969    }
970
971    /// Update configuration
972    pub fn set_config(&mut self, config: SpoolerConfig) {
973        self.config = config;
974    }
975
976    /// List currently spooled files
977    pub async fn list_spooled_files(&self) -> Vec<PathBuf> {
978        self.spooled_files.read().await.clone()
979    }
980}
981
982/// Sanitize tool name for use in filename
983fn sanitize_tool_name(name: &str) -> String {
984    name.chars()
985        .map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' })
986        .collect()
987}
988
989/// Extension trait for integrating spooler with tool results
990pub trait SpoolableOutput {
991    /// Check if this output should be spooled
992    fn should_spool(&self, threshold_bytes: usize) -> bool;
993
994    /// Get the byte size of this output
995    fn byte_size(&self) -> usize;
996}
997
998impl SpoolableOutput for Value {
999    fn should_spool(&self, threshold_bytes: usize) -> bool {
1000        self.to_string().len() > threshold_bytes
1001    }
1002
1003    fn byte_size(&self) -> usize {
1004        self.to_string().len()
1005    }
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010    use super::*;
1011    use tempfile::tempdir;
1012
1013    #[tokio::test]
1014    async fn test_spooler_creation() {
1015        let temp = tempdir().unwrap();
1016        let spooler = ToolOutputSpooler::new(temp.path());
1017
1018        assert!(spooler.config.enabled);
1019        assert_eq!(spooler.config.threshold_bytes, DEFAULT_SPOOL_THRESHOLD_BYTES);
1020        assert_eq!(spooler.config.max_age_secs, default_max_age_secs());
1021    }
1022
1023    #[tokio::test]
1024    async fn test_should_spool_small_value() {
1025        let temp = tempdir().unwrap();
1026        let spooler = ToolOutputSpooler::new(temp.path());
1027
1028        let small_value = json!({"result": "ok"});
1029        assert!(!spooler.should_spool(&small_value));
1030    }
1031
1032    #[tokio::test]
1033    async fn test_should_spool_large_value() {
1034        let temp = tempdir().unwrap();
1035        let config = SpoolerConfig { threshold_bytes: 100, ..Default::default() }; // Low threshold for testing
1036        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1037
1038        let large_content = "x".repeat(200);
1039        let large_value = json!({"content": large_content});
1040        assert!(spooler.should_spool(&large_value));
1041    }
1042
1043    #[tokio::test]
1044    async fn test_should_not_spool_when_disabled() {
1045        let temp = tempdir().unwrap();
1046        let config = SpoolerConfig { threshold_bytes: 100, ..Default::default() }; // Low threshold for testing
1047        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1048
1049        let large_content = "x".repeat(200);
1050        let large_value = json!({"output": large_content, "no_spool": true});
1051        assert!(!spooler.should_spool(&large_value));
1052    }
1053
1054    #[tokio::test]
1055    async fn test_spool_output() {
1056        let temp = tempdir().unwrap();
1057        let config = SpoolerConfig { threshold_bytes: 50, ..Default::default() };
1058        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1059
1060        let content = "Line 1\nLine 2\nLine 3\n".repeat(10);
1061        let value = json!({"output": content});
1062
1063        let result = spooler.spool_output("test_tool", &value, false).await.unwrap();
1064
1065        assert!(result.file_path.to_string_lossy().contains("test_tool"));
1066        assert!(result.original_bytes > 0);
1067
1068        // Verify file was created
1069        let full_path = temp.path().join(&result.file_path);
1070        assert!(full_path.exists());
1071    }
1072
1073    #[tokio::test]
1074    async fn test_process_output_small() {
1075        let temp = tempdir().unwrap();
1076        let spooler = ToolOutputSpooler::new(temp.path());
1077
1078        let small_value = json!({"result": "ok"});
1079        let result = spooler.process_output("test", small_value.clone(), false).await.unwrap();
1080
1081        // Should return original value unchanged
1082        assert_eq!(result, small_value);
1083    }
1084
1085    #[tokio::test]
1086    async fn test_process_output_large() {
1087        let temp = tempdir().unwrap();
1088        let config = SpoolerConfig { threshold_bytes: 50, ..Default::default() };
1089        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1090
1091        let large_value = json!({"content": "x".repeat(200)});
1092        let result = spooler.process_output("test", large_value, false).await.unwrap();
1093
1094        assert!(result.get("spool_path").is_some());
1095        assert!(result.get("spooled_to_file").is_none());
1096        assert!(result.get("content").is_some());
1097        assert!(result.get("spool_path").is_some());
1098        assert!(result.get("file_path").is_none());
1099        assert!(result.get("truncated").is_none());
1100        assert!(result.get("omitted_bytes").is_none());
1101    }
1102
1103    #[test]
1104    fn pending_bounded_read_trims_incomplete_utf8_character() {
1105        let temp = tempdir().unwrap();
1106        let output_dir = temp.path().join(TOOL_OUTPUT_DIR);
1107        std::fs::create_dir_all(&output_dir).unwrap();
1108        let spool_path = output_dir.join("pending.txt");
1109        std::fs::write(&spool_path, "prefix🙂suffix").unwrap();
1110
1111        let reference_value = json!({
1112            "spool_path": ".vtcode/context/tool_outputs/pending.txt",
1113            "spool_state": "pending",
1114        });
1115        let reference = SpooledOutputReference::from_value(&reference_value).unwrap();
1116
1117        // `🙂` is four bytes. Stop after its first byte and retain the valid
1118        // prefix instead of making the entire preview unavailable.
1119        let preview = reference.read_pending_bounded(temp.path(), "prefix".len() + 1).unwrap();
1120        assert_eq!(preview, "prefix");
1121    }
1122
1123    #[tokio::test]
1124    async fn completed_spool_reference_detects_tampering_and_legacy_metadata() {
1125        let temp = tempdir().unwrap();
1126        let config = SpoolerConfig { threshold_bytes: 50, ..Default::default() };
1127        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1128        let expected = "x".repeat(200);
1129        let result = spooler
1130            .process_output("integrity", json!({"content": expected}), false)
1131            .await
1132            .expect("spool output");
1133        let reference = SpooledOutputReference::from_value(&result).expect("typed spool reference");
1134        let persisted = reference.read_verified_completed(temp.path()).expect("verified read");
1135        assert!(persisted.contains(&expected));
1136
1137        let path = temp.path().join(reference.spool_path);
1138        std::fs::write(&path, "y".repeat(persisted.len())).expect("tamper fixture");
1139        assert!(reference.read_verified_completed(temp.path()).is_err());
1140
1141        let legacy = json!({
1142            "spool_path": reference.spool_path,
1143            "spool_complete": true,
1144        });
1145        let legacy_reference = SpooledOutputReference::from_value(&legacy).expect("legacy reference");
1146        assert!(legacy_reference.read_verified_completed(temp.path()).is_err());
1147    }
1148
1149    #[test]
1150    fn test_sanitize_tool_name() {
1151        assert_eq!(sanitize_tool_name("read_file"), "read_file");
1152        assert_eq!(sanitize_tool_name("mcp/fetch"), "mcp_fetch");
1153        assert_eq!(sanitize_tool_name("tool-name"), "tool_name");
1154    }
1155
1156    #[tokio::test]
1157    async fn test_read_file_spools_raw_content() {
1158        let temp = tempdir().unwrap();
1159        let config = SpoolerConfig { threshold_bytes: 50, ..Default::default() };
1160        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1161
1162        let file_content = "fn main() {\n    println!(\"Hello, world!\");\n}\n// More code here...";
1163
1164        // Simulate a read_file response with content field
1165        let read_file_response = json!({
1166            "success": true,
1167            "content": file_content,
1168            "path": "test.rs"
1169        });
1170
1171        let result = spooler.process_output("read_file", read_file_response, false).await.unwrap();
1172
1173        // Should include source_path for read_file
1174        let source_path = result.get("source_path").and_then(|v| v.as_str()).unwrap();
1175        assert_eq!(source_path, "test.rs");
1176
1177        let content_field = result.get("content").and_then(|v| v.as_str()).unwrap();
1178        assert!(content_field.contains("fn main()"));
1179        assert!(!content_field.contains("\"success\"")); // Should not show JSON structure
1180
1181        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
1182        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
1183        assert_eq!(spooled_content, file_content);
1184        assert!(!spooled_content.contains("\"success\"")); // Raw content, not JSON
1185    }
1186
1187    #[tokio::test]
1188    async fn test_run_pty_cmd_spools_raw_output() {
1189        let temp = tempdir().unwrap();
1190        let config = SpoolerConfig { threshold_bytes: 50, ..Default::default() };
1191        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1192
1193        let command_output = "   Compiling vtcode-core v0.68.1\n   Checking vtcode-core v0.68.1\n    Finished dev [unoptimized + debuginfo] target(s)";
1194
1195        // Simulate a run_pty_cmd response with output field
1196        let pty_response = json!({
1197            "output": command_output,
1198            "exit_code": 0,
1199            "wall_time": 1.234,
1200            "success": true
1201        });
1202
1203        let result = spooler.process_output("run_pty_cmd", pty_response, false).await.unwrap();
1204
1205        // Should return file reference
1206        assert!(result.get("spool_path").is_some());
1207        assert!(result.get("spooled_to_file").is_none());
1208
1209        // Verify spooled file contains raw output, not JSON wrapper
1210        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
1211        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
1212        assert_eq!(spooled_content, command_output);
1213        assert!(!spooled_content.contains("\"output\""));
1214        assert!(!spooled_content.contains("\"exit_code\""));
1215    }
1216
1217    #[tokio::test]
1218    async fn test_pty_tools_spool_raw_output() {
1219        let temp = tempdir().unwrap();
1220        let config = SpoolerConfig { threshold_bytes: 50, ..Default::default() };
1221        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1222
1223        let command_output = "Some command output text\nwith multiple lines\nfor testing";
1224
1225        let send_input_response = json!({
1226            "output": command_output,
1227            "wall_time": 0.123,
1228            "session_id": "session123"
1229        });
1230
1231        let result = spooler
1232            .process_output("send_pty_input", send_input_response, false)
1233            .await
1234            .unwrap();
1235
1236        assert!(result.get("spool_path").is_some());
1237        assert!(result.get("spooled_to_file").is_none());
1238        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
1239        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
1240        assert_eq!(spooled_content, command_output);
1241        assert!(!spooled_content.contains("\"output\""));
1242
1243        let read_session_response = json!({
1244            "output": command_output,
1245            "wall_time": 0.456
1246        });
1247
1248        let result = spooler
1249            .process_output("read_pty_session", read_session_response, false)
1250            .await
1251            .unwrap();
1252
1253        assert!(result.get("spool_path").is_some());
1254        assert!(result.get("spooled_to_file").is_none());
1255        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
1256        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
1257        assert_eq!(spooled_content, command_output);
1258        assert!(!spooled_content.contains("\"output\""));
1259    }
1260
1261    #[tokio::test]
1262    async fn test_forced_pty_spool_keeps_structured_continuation_metadata() {
1263        let temp = tempdir().unwrap();
1264        let config = SpoolerConfig { threshold_bytes: 999_999, ..Default::default() }; // ensure only force triggers
1265        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1266
1267        let output = "x".repeat(10_000);
1268        let value = json!({
1269            "output": output,
1270            "process_id": "run-abc123",
1271            "next_continue_args": {
1272                "session_id": "run-abc123"
1273            },
1274            "truncated": true
1275        });
1276
1277        let result = spooler
1278            .process_output_with_force("run_pty_cmd", value, false, true)
1279            .await
1280            .unwrap();
1281
1282        assert_eq!(result.get("process_id").and_then(|v| v.as_str()), Some("run-abc123"));
1283        assert_eq!(
1284            result.get("next_continue_args"),
1285            Some(&json!({
1286                "session_id": "run-abc123"
1287            }))
1288        );
1289        assert!(result.get("follow_up_prompt").is_none());
1290        assert_eq!(result.get("truncated").and_then(|v| v.as_bool()), Some(true));
1291        assert!(result.get("spooled_to_file").is_none());
1292        assert!(
1293            result
1294                .get("output")
1295                .and_then(|v| v.as_str())
1296                .is_some_and(|s| s.contains("bytes omitted"))
1297        );
1298        assert!(result.get("spool_hint").is_none());
1299    }
1300
1301    #[test]
1302    fn spool_line_counts_handle_empty_chunks_and_unterminated_lines() {
1303        for (chunks, expected) in [
1304            (vec![""], 0),
1305            (vec!["abc", "", "def"], 1),
1306            (vec!["a\n", "\n", ""], 2),
1307            (vec!["first\r", "\nsecond\n", "last"], 3),
1308            (vec!["日本語", "\n", "final"], 2),
1309        ] {
1310            let mut counter = SpoolLineCounter::default();
1311            for chunk in chunks {
1312                counter.append(chunk.as_bytes());
1313            }
1314            assert_eq!(counter.line_count(), expected);
1315        }
1316    }
1317
1318    #[tokio::test]
1319    async fn test_exec_command_spools_raw_output() {
1320        let temp = tempdir().unwrap();
1321        let config = SpoolerConfig { threshold_bytes: 50, ..Default::default() };
1322        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1323
1324        let command_output = "   Compiling vtcode-core v0.68.1\n   Checking vtcode-core v0.68.1\n    Finished dev";
1325
1326        let exec_command_response = json!({
1327            "output": command_output,
1328            "exit_code": 0,
1329            "wall_time": 1.234,
1330            "success": true
1331        });
1332
1333        let result = spooler
1334            .process_output(tools::EXEC_COMMAND, exec_command_response, false)
1335            .await
1336            .unwrap();
1337
1338        assert!(result.get("spool_path").is_some());
1339        assert!(result.get("spooled_to_file").is_none());
1340
1341        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
1342        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
1343        assert_eq!(spooled_content, command_output);
1344        assert_eq!(result["spool_line_count"], 3);
1345        assert!(result["spool_note"].as_str().unwrap().contains("EOF at line 3"));
1346        assert!(!spooled_content.contains("\"output\""));
1347        assert!(!spooled_content.contains("\"exit_code\""));
1348    }
1349
1350    #[tokio::test]
1351    async fn test_exec_command_spools_internal_raw_output_over_preview() {
1352        let temp = tempdir().unwrap();
1353        let config = SpoolerConfig { threshold_bytes: 10, ..Default::default() };
1354        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1355
1356        let raw_output = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6";
1357        let preview_output = "line 1\nline 2\n[Output truncated]";
1358        let exec_command_response = json!({
1359            "output": preview_output,
1360            "raw_output": raw_output,
1361            "truncated": true,
1362            "exit_code": 0,
1363            "wall_time": 1.234,
1364            "success": true
1365        });
1366
1367        let result = spooler
1368            .process_output(tools::EXEC_COMMAND, exec_command_response, false)
1369            .await
1370            .unwrap();
1371
1372        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
1373        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
1374        assert_eq!(spooled_content, raw_output);
1375        assert_ne!(spooled_content, preview_output);
1376        let spool_note = result.get("spool_note").and_then(Value::as_str).unwrap();
1377        assert!(spool_note.contains("exec_command"));
1378        assert!(!spool_note.contains("unified_file"));
1379        assert!(!spool_note.contains("unified_search"));
1380        assert!(!spool_note.contains("unified_exec"));
1381    }
1382
1383    #[tokio::test]
1384    async fn test_double_serialized_pty_output() {
1385        let temp = tempdir().unwrap();
1386        let config = SpoolerConfig { threshold_bytes: 50, ..Default::default() };
1387        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1388
1389        let command_output = "   Compiling vtcode-core v0.68.1\n   Checking vtcode-core v0.68.1\n    Finished dev";
1390
1391        let inner_json = json!({
1392            "output": command_output,
1393            "exit_code": 0,
1394            "wall_time": 1.234,
1395            "success": true
1396        });
1397        let double_serialized = json!(serde_json::to_string(&inner_json).unwrap());
1398
1399        let result = spooler.process_output("run_pty_cmd", double_serialized, false).await.unwrap();
1400
1401        assert!(result.get("spool_path").is_some());
1402        assert!(result.get("spooled_to_file").is_none());
1403
1404        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
1405        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
1406        assert_eq!(spooled_content, command_output);
1407        assert!(!spooled_content.contains("\"output\""));
1408    }
1409
1410    #[tokio::test]
1411    async fn test_bash_and_shell_spool_raw_output() {
1412        let temp = tempdir().unwrap();
1413        let config = SpoolerConfig { threshold_bytes: 50, ..Default::default() };
1414        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1415
1416        let command_output = "total 32\ndrwxr-xr-x  10 user  staff   320 Jan  1 12:00 .";
1417
1418        let bash_response = json!({
1419            "output": command_output,
1420            "exit_code": 0,
1421            "wall_time": 0.1
1422        });
1423
1424        let result = spooler.process_output("bash", bash_response, false).await.unwrap();
1425
1426        assert!(result.get("spool_path").is_some());
1427        assert!(result.get("spooled_to_file").is_none());
1428        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
1429        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
1430        assert_eq!(spooled_content, command_output);
1431
1432        let shell_response = json!({
1433            "output": command_output,
1434            "exit_code": 0,
1435            "wall_time": 0.2
1436        });
1437
1438        let result = spooler.process_output("shell", shell_response, false).await.unwrap();
1439
1440        assert!(result.get("spool_path").is_some());
1441        assert!(result.get("spooled_to_file").is_none());
1442        let spooled_path = result.get("spool_path").and_then(|v| v.as_str()).unwrap();
1443        let spooled_content = std::fs::read_to_string(temp.path().join(spooled_path)).unwrap();
1444        assert_eq!(spooled_content, command_output);
1445    }
1446
1447    #[test]
1448    fn test_condense_content_short() {
1449        let short = "a".repeat(CONDENSE_HEAD_BYTES + CONDENSE_TAIL_BYTES);
1450        let result = condense_content(&short);
1451        assert_eq!(result, short);
1452    }
1453
1454    #[test]
1455    fn test_condense_content_long() {
1456        let total = 20_000;
1457        let long_content = "a".repeat(total);
1458        let result = condense_content(&long_content);
1459        assert!(result.contains("bytes omitted"));
1460        assert!(result.len() < total);
1461        assert!(result.starts_with(&"a".repeat(100)));
1462        assert!(result.ends_with(&"a".repeat(100)));
1463    }
1464
1465    #[test]
1466    fn test_condense_content_utf8_boundary() {
1467        let mut content = "a".repeat(CONDENSE_HEAD_BYTES - 1);
1468        content.push('é'); // 2-byte char at boundary
1469        content.push_str(&"b".repeat(20_000));
1470        let result = condense_content(&content);
1471        assert!(result.contains("bytes omitted"));
1472        assert!(result.is_char_boundary(0));
1473    }
1474
1475    #[test]
1476    fn test_tail_preview_content_shows_only_tail() {
1477        let input = (0..200).map(|i| format!("line-{i}")).collect::<Vec<_>>().join("\n");
1478        let preview = tail_preview_text(&input, 500, 10);
1479        assert!(preview.contains("omitted"));
1480        assert!(preview.contains("line-199"));
1481        assert!(!preview.contains("line-1\n"));
1482    }
1483
1484    #[tokio::test]
1485    async fn test_estimate_size_content_field() {
1486        let temp = tempdir().unwrap();
1487        let spooler = ToolOutputSpooler::new(temp.path());
1488
1489        let val = json!({"content": "hello world"});
1490        assert_eq!(spooler.estimate_size(&val), 11);
1491    }
1492
1493    #[tokio::test]
1494    async fn test_estimate_size_output_field() {
1495        let temp = tempdir().unwrap();
1496        let spooler = ToolOutputSpooler::new(temp.path());
1497
1498        let val = json!({"output": "some output"});
1499        assert_eq!(spooler.estimate_size(&val), 11);
1500    }
1501
1502    #[tokio::test]
1503    async fn test_estimate_size_string_value() {
1504        let temp = tempdir().unwrap();
1505        let spooler = ToolOutputSpooler::new(temp.path());
1506
1507        let val = json!("raw string");
1508        assert_eq!(spooler.estimate_size(&val), 10);
1509    }
1510
1511    #[tokio::test]
1512    async fn test_estimate_size_fallback() {
1513        let temp = tempdir().unwrap();
1514        let spooler = ToolOutputSpooler::new(temp.path());
1515
1516        let val = json!({"some_key": 42});
1517        assert!(spooler.estimate_size(&val) > 0);
1518    }
1519
1520    #[tokio::test]
1521    async fn test_cleanup_old_files_respects_configured_max_age() {
1522        let temp = tempdir().unwrap();
1523        let config = SpoolerConfig {
1524            threshold_bytes: 1,
1525            max_age_secs: 0,
1526            ..Default::default()
1527        };
1528        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1529        let value = json!({"output": "old output"});
1530
1531        let result = spooler.spool_output("test_tool", &value, false).await.unwrap();
1532        let full_path = temp.path().join(&result.file_path);
1533        assert!(full_path.exists());
1534
1535        tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
1536
1537        let removed = spooler.cleanup_old_files().await.unwrap();
1538        assert_eq!(removed, 1);
1539        assert!(!full_path.exists());
1540    }
1541
1542    #[tokio::test]
1543    async fn test_cleanup_enforces_max_files_across_sessions() {
1544        // Simulate leftovers from prior sessions: files exist on disk but are
1545        // not in this instance's tracking vec. max_files must still bound them.
1546        let temp = tempdir().unwrap();
1547        let output_dir = temp.path().join(TOOL_OUTPUT_DIR);
1548        std::fs::create_dir_all(&output_dir).unwrap();
1549        for index in 0..8 {
1550            std::fs::write(output_dir.join(format!("stale_{index}.txt")), b"leftover").unwrap();
1551            // Ensure distinct mtimes so oldest-first eviction is deterministic.
1552            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1553        }
1554
1555        let config = SpoolerConfig {
1556            threshold_bytes: 1,
1557            // Keep everything young so only the count budget applies.
1558            max_age_secs: 3600,
1559            max_files: 3,
1560            ..Default::default()
1561        };
1562        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1563        let removed = spooler.cleanup_old_files().await.unwrap();
1564        assert_eq!(removed, 5, "startup prune must drop overflow past max_files");
1565
1566        let remaining = std::fs::read_dir(&output_dir).unwrap().count();
1567        assert_eq!(remaining, 3, "on-disk spool count must respect max_files");
1568    }
1569
1570    #[tokio::test]
1571    async fn test_cleanup_skips_pinned_blocked_turn_outputs() {
1572        let temp = tempdir().unwrap();
1573        let config = SpoolerConfig {
1574            threshold_bytes: 1,
1575            max_age_secs: 0,
1576            ..Default::default()
1577        };
1578        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1579        let value = json!({"output": "blocked output"});
1580
1581        let result = spooler.spool_output("exec_command", &value, false).await.unwrap();
1582        let full_path = temp.path().join(&result.file_path);
1583        assert!(full_path.exists());
1584
1585        spooler.pin_spool_files(std::slice::from_ref(&full_path)).await;
1586        tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
1587
1588        let removed = spooler.cleanup_old_files().await.unwrap();
1589        assert_eq!(removed, 0);
1590        assert!(full_path.exists());
1591
1592        spooler.unpin_spool_files(std::slice::from_ref(&full_path)).await;
1593        let removed = spooler.cleanup_old_files().await.unwrap();
1594        assert_eq!(removed, 1);
1595        assert!(!full_path.exists());
1596    }
1597
1598    #[tokio::test]
1599    async fn test_cleanup_expired_files_never_enforces_count_budget() {
1600        // Startup pruning runs in a fresh process whose pin set cannot see a
1601        // concurrent session's young spools, so it must be age-only: young
1602        // files beyond max_files stay on disk until the in-session periodic
1603        // cleanup enforces the budget.
1604        let temp = tempdir().unwrap();
1605        let output_dir = temp.path().join(".vtcode").join("context").join("tool_outputs");
1606        std::fs::create_dir_all(&output_dir).unwrap();
1607        for i in 0..8 {
1608            std::fs::write(output_dir.join(format!("young-{i}.out")), b"young").unwrap();
1609        }
1610
1611        let config = SpoolerConfig {
1612            threshold_bytes: 1,
1613            max_age_secs: 3600,
1614            max_files: 3,
1615            ..Default::default()
1616        };
1617        let spooler = ToolOutputSpooler::with_config(temp.path(), config);
1618
1619        let removed = spooler.cleanup_expired_files().await.unwrap();
1620        assert_eq!(removed, 0, "startup prune must not evict young files past max_files");
1621        assert_eq!(std::fs::read_dir(&output_dir).unwrap().count(), 8, "all young spools survive the startup prune");
1622
1623        let removed = spooler.cleanup_old_files().await.unwrap();
1624        assert_eq!(removed, 5, "in-session cleanup still enforces the count budget");
1625        assert_eq!(std::fs::read_dir(&output_dir).unwrap().count(), 3);
1626    }
1627}