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 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
105pub(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#[derive(Debug, Clone, Copy, Eq, PartialEq)]
130enum SpoolCleanupScope {
131 AgeOnly,
133 AgeAndCount,
135}
136
137impl SpoolCleanupScope {
138 fn enforces_count_budget(self) -> bool {
139 matches!(self, SpoolCleanupScope::AgeAndCount)
140 }
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct SpoolerConfig {
146 #[serde(default = "default_enabled")]
148 pub enabled: bool,
149
150 #[serde(default = "default_threshold")]
152 pub threshold_bytes: usize,
153
154 #[serde(default = "default_max_files")]
156 pub max_files: usize,
157
158 #[serde(default = "default_max_age_secs")]
160 pub max_age_secs: u64,
161
162 #[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#[derive(Debug, Clone)]
201pub struct SpoolResult {
202 pub file_path: PathBuf,
204 pub original_bytes: usize,
206 pub byte_count: u64,
208 pub sha256: String,
210 pub state: SpoolState,
212 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#[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#[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 #[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 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 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
381fn 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 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
435pub(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
498const CLEANUP_EVERY_N_SPOOLS: u64 = 50;
500
501pub struct ToolOutputSpooler {
503 workspace_root: PathBuf,
505 output_dir: PathBuf,
507 config: SpoolerConfig,
509 spooled_files: Arc<RwLock<Vec<PathBuf>>>,
511 pinned_files: Arc<RwLock<std::collections::HashSet<PathBuf>>>,
515 spool_count: std::sync::atomic::AtomicU64,
517}
518
519impl ToolOutputSpooler {
520 pub fn new(workspace_root: &Path) -> Self {
522 Self::with_config(workspace_root, SpoolerConfig::default())
523 }
524
525 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 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 pub async fn spool_output(&self, tool_name: &str, value: &Value, is_mcp: bool) -> Result<SpoolResult> {
569 let timestamp = std::time::SystemTime::now()
572 .duration_since(std::time::UNIX_EPOCH)
573 .unwrap_or_default()
574 .as_micros();
575
576 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 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 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 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 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 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 json_str.to_string()
638 }
639 } else {
640 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 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 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 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 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 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 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 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 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 pub async fn cleanup_old_files(&self) -> Result<usize> {
889 self.cleanup_files(SpoolCleanupScope::AgeAndCount).await
890 }
891
892 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 let mut paths_to_remove = Vec::new();
915 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 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 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 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 pub fn output_dir(&self) -> &Path {
971 &self.output_dir
972 }
973
974 pub fn config(&self) -> &SpoolerConfig {
976 &self.config
977 }
978
979 pub fn set_config(&mut self, config: SpoolerConfig) {
981 self.config = config;
982 }
983
984 pub async fn list_spooled_files(&self) -> Vec<PathBuf> {
986 self.spooled_files.read().await.clone()
987 }
988}
989
990fn sanitize_tool_name(name: &str) -> String {
992 name.chars()
993 .map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' })
994 .collect()
995}
996
997pub trait SpoolableOutput {
999 fn should_spool(&self, threshold_bytes: usize) -> bool;
1001
1002 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() }; 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() }; 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 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 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 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 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 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\"")); 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\"")); }
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 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 assert!(result.get("spool_path").is_some());
1215 assert!(result.get("spooled_to_file").is_none());
1216
1217 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() }; 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('é'); 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 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 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 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1589 }
1590
1591 let config = SpoolerConfig {
1592 threshold_bytes: 1,
1593 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 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}