1use 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
36pub 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
97pub(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#[derive(Debug, Clone, Copy, Eq, PartialEq)]
122enum SpoolCleanupScope {
123 AgeOnly,
125 AgeAndCount,
127}
128
129impl SpoolCleanupScope {
130 fn enforces_count_budget(self) -> bool {
131 matches!(self, SpoolCleanupScope::AgeAndCount)
132 }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct SpoolerConfig {
138 #[serde(default = "default_enabled")]
140 pub enabled: bool,
141
142 #[serde(default = "default_threshold")]
144 pub threshold_bytes: usize,
145
146 #[serde(default = "default_max_files")]
148 pub max_files: usize,
149
150 #[serde(default = "default_max_age_secs")]
152 pub max_age_secs: u64,
153
154 #[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#[derive(Debug, Clone)]
193pub struct SpoolResult {
194 pub file_path: PathBuf,
196 pub original_bytes: usize,
198 pub byte_count: u64,
200 pub sha256: String,
202 pub state: SpoolState,
204 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#[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#[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 #[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 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 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
373fn 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 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
427pub(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
490const CLEANUP_EVERY_N_SPOOLS: u64 = 50;
492
493pub struct ToolOutputSpooler {
495 workspace_root: PathBuf,
497 output_dir: PathBuf,
499 config: SpoolerConfig,
501 spooled_files: Arc<RwLock<Vec<PathBuf>>>,
503 pinned_files: Arc<RwLock<std::collections::HashSet<PathBuf>>>,
507 spool_count: std::sync::atomic::AtomicU64,
509}
510
511impl ToolOutputSpooler {
512 pub fn new(workspace_root: &Path) -> Self {
514 Self::with_config(workspace_root, SpoolerConfig::default())
515 }
516
517 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 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 pub async fn spool_output(&self, tool_name: &str, value: &Value, is_mcp: bool) -> Result<SpoolResult> {
561 let timestamp = std::time::SystemTime::now()
564 .duration_since(std::time::UNIX_EPOCH)
565 .unwrap_or_default()
566 .as_micros();
567
568 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 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 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 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 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 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 json_str.to_string()
630 }
631 } else {
632 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 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 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 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 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 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 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 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 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 pub async fn cleanup_old_files(&self) -> Result<usize> {
881 self.cleanup_files(SpoolCleanupScope::AgeAndCount).await
882 }
883
884 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 let mut paths_to_remove = Vec::new();
907 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 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 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 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 pub fn output_dir(&self) -> &Path {
963 &self.output_dir
964 }
965
966 pub fn config(&self) -> &SpoolerConfig {
968 &self.config
969 }
970
971 pub fn set_config(&mut self, config: SpoolerConfig) {
973 self.config = config;
974 }
975
976 pub async fn list_spooled_files(&self) -> Vec<PathBuf> {
978 self.spooled_files.read().await.clone()
979 }
980}
981
982fn sanitize_tool_name(name: &str) -> String {
984 name.chars()
985 .map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' })
986 .collect()
987}
988
989pub trait SpoolableOutput {
991 fn should_spool(&self, threshold_bytes: usize) -> bool;
993
994 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() }; 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() }; 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 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 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 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 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 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\"")); 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\"")); }
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 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 assert!(result.get("spool_path").is_some());
1207 assert!(result.get("spooled_to_file").is_none());
1208
1209 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() }; 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('é'); 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 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 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1553 }
1554
1555 let config = SpoolerConfig {
1556 threshold_bytes: 1,
1557 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 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}