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