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