1use std::path::PathBuf;
21
22use crate::interpreter::ExecResult;
23#[cfg(feature = "localfs")]
24use crate::paths;
25
26const DEFAULT_AGENT_LIMIT: usize = 8 * 1024;
28
29const DEFAULT_HEAD_BYTES: usize = 1024;
31
32const DEFAULT_TAIL_BYTES: usize = 512;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42#[non_exhaustive]
43pub enum SpillMode {
44 #[default]
52 Disk,
53 Memory,
57}
58
59#[derive(Debug, Clone)]
64pub struct OutputLimitConfig {
65 max_bytes: Option<usize>,
66 head_bytes: usize,
67 tail_bytes: usize,
68 spill_mode: SpillMode,
69}
70
71impl OutputLimitConfig {
72 pub fn none() -> Self {
74 Self {
75 max_bytes: None,
76 head_bytes: DEFAULT_HEAD_BYTES,
77 tail_bytes: DEFAULT_TAIL_BYTES,
78 spill_mode: SpillMode::Disk,
79 }
80 }
81
82 pub fn default_limit() -> usize {
84 DEFAULT_AGENT_LIMIT
85 }
86
87 pub fn agent() -> Self {
89 Self {
90 max_bytes: Some(DEFAULT_AGENT_LIMIT),
91 head_bytes: DEFAULT_HEAD_BYTES,
92 tail_bytes: DEFAULT_TAIL_BYTES,
93 spill_mode: SpillMode::Disk,
94 }
95 }
96
97 pub fn in_memory(mut self) -> Self {
105 self.spill_mode = SpillMode::Memory;
106 self
107 }
108
109 pub fn is_enabled(&self) -> bool {
111 self.max_bytes.is_some()
112 }
113
114 pub fn spill_mode(&self) -> SpillMode {
116 self.spill_mode
117 }
118
119 pub fn set_spill_mode(&mut self, mode: SpillMode) {
121 self.spill_mode = mode;
122 }
123
124 pub fn max_bytes(&self) -> Option<usize> {
126 self.max_bytes
127 }
128
129 pub fn head_bytes(&self) -> usize {
131 self.head_bytes
132 }
133
134 pub fn tail_bytes(&self) -> usize {
136 self.tail_bytes
137 }
138
139 pub fn set_limit(&mut self, max: Option<usize>) {
141 self.max_bytes = max;
142 }
143
144 pub fn set_head_bytes(&mut self, bytes: usize) {
146 self.head_bytes = bytes;
147 }
148
149 pub fn set_tail_bytes(&mut self, bytes: usize) {
151 self.tail_bytes = bytes;
152 }
153}
154
155pub struct SpillResult {
157 pub path: PathBuf,
158 pub total_bytes: usize,
159}
160
161pub async fn spill_if_needed(
173 result: &mut ExecResult,
174 config: &OutputLimitConfig,
175) -> Option<SpillResult> {
176 let max = config.max_bytes?;
177
178 #[cfg(feature = "localfs")]
188 let ring_already_overflowed = result.did_spill;
189
190 if let Some(total) = result.out_bytes().map(|b| b.len()) {
194 if total <= max {
195 return None;
196 }
197 #[cfg(feature = "localfs")]
198 if config.spill_mode == SpillMode::Disk {
199 let bytes = result.out_bytes().unwrap_or_default().to_vec();
200 return match write_spill_file(&bytes).await {
201 Ok((path, written)) => {
202 result.set_out(format!(
203 "[binary output: {total} bytes spilled to {} — read it with `cat {}`]",
204 path.display(),
205 path.display()
206 ));
207 result.did_spill = true;
208 Some(SpillResult { path, total_bytes: written })
209 }
210 Err(e) => {
211 tracing::error!("binary output spill failed: {}", e);
212 *result = ExecResult::failure(
213 1,
214 format!(
215 "binary output exceeded {max} byte limit ({total} bytes) and spill \
216 to disk failed: {e}"
217 ),
218 );
219 None
220 }
221 };
222 }
223 let bytes = result.out_bytes().unwrap_or_default().to_vec();
226 let head_n = config.head_bytes.min(bytes.len());
227 let tail_n = config.tail_bytes.min(bytes.len().saturating_sub(head_n));
228 let mut truncated = bytes[..head_n].to_vec();
229 truncated.extend_from_slice(&bytes[bytes.len() - tail_n..]);
230 result.set_out_bytes(truncated);
231 result.did_spill = true;
232 return None;
233 }
234
235 #[cfg(feature = "localfs")]
238 if config.spill_mode == SpillMode::Disk {
239 if !result.text_out().is_empty() && !result.has_output() {
241 let total = result.text_out().len();
242 if total <= max {
243 return None;
244 }
245 return spill_string(result, config, max, ring_already_overflowed).await;
246 }
247
248 let estimate = result.output().map(|o| o.estimated_byte_size());
250 if let Some(estimate) = estimate {
251 if estimate <= max {
252 if result.text_out().len() <= max {
259 return None;
260 }
261 result.materialize();
264 return spill_string(result, config, max, ring_already_overflowed).await;
265 }
266
267 return spill_output_data(result, config, max, ring_already_overflowed).await;
269 }
270
271 return None;
272 }
273
274 truncate_in_memory(result, config, max)
276}
277
278pub async fn apply_spill_contract(result: &mut ExecResult, config: &OutputLimitConfig) {
293 if config.is_enabled() {
294 let _ = spill_if_needed(result, config).await;
295 }
296 if result.did_spill {
297 if result.original_code.is_none() {
303 result.original_code = Some(result.code);
304 }
305 result.code = 3;
306 }
307}
308
309fn truncate_in_memory(
322 result: &mut ExecResult,
323 config: &OutputLimitConfig,
324 max: usize,
325) -> Option<SpillResult> {
326 if let Some(output) = result.output() {
330 let estimate = output.estimated_byte_size();
331 if estimate > max {
332 let mut buf = Vec::with_capacity(config.head_bytes + 64);
334 let _ = output.write_canonical(&mut buf, Some(config.head_bytes));
336 let s = String::from_utf8_lossy(&buf);
337 let head = truncate_to_char_boundary(&s, config.head_bytes);
338 let truncated = format!(
339 "{}\n...\n[output truncated in memory: ~{} bytes (exceeds {} byte limit) — head only, no spill file]",
340 head, estimate, max
341 );
342 result.set_out(truncated);
343 result.set_output(None);
347 result.did_spill = true;
348 return None;
349 }
350 }
352
353 let total = result.text_out().len();
354 if total <= max {
355 return None;
356 }
357
358 let text = result.text_out().into_owned();
361 let head = truncate_to_char_boundary(&text, config.head_bytes);
362 let tail = tail_from_str(&text, config.tail_bytes);
363 let truncated = format!(
364 "{}\n...\n{}\n[output truncated in memory: {} bytes total — no spill file]",
365 head, tail, total
366 );
367 result.set_out(truncated);
368 result.set_output(None);
370 result.did_spill = true;
371 None
372}
373
374#[cfg(feature = "localfs")]
381async fn spill_string(
382 result: &mut ExecResult,
383 config: &OutputLimitConfig,
384 max: usize,
385 ring_already_overflowed: bool,
386) -> Option<SpillResult> {
387 let total = result.text_out().len();
388 match write_spill_file(result.text_out().as_bytes()).await {
389 Ok((path, written)) => {
390 let truncated =
391 build_truncated_output(&result.text_out(), config, &path, total, ring_already_overflowed);
392 result.set_out(truncated);
393 result.did_spill = true;
394 Some(SpillResult {
395 path,
396 total_bytes: written,
397 })
398 }
399 Err(e) => {
400 tracing::error!("output spill failed: {}", e);
401 *result = ExecResult::failure(1, format!(
402 "output exceeded {} byte limit ({} bytes) and spill to disk failed: {}",
403 max, total, e
404 ));
405 None
406 }
407 }
408}
409
410#[cfg(feature = "localfs")]
415async fn spill_output_data(
416 result: &mut ExecResult,
417 config: &OutputLimitConfig,
418 max: usize,
419 ring_already_overflowed: bool,
420) -> Option<SpillResult> {
421 let output = result.output()?;
422
423 let dir = paths::spill_dir();
424 if let Err(e) = tokio::fs::create_dir_all(&dir).await {
425 tracing::error!("output spill dir creation failed: {}", e);
426 *result = ExecResult::failure(1, format!(
427 "output exceeded {} byte limit and spill dir creation failed: {}", max, e
428 ));
429 return None;
430 }
431
432 let filename = generate_spill_filename();
433 let path = dir.join(&filename);
434
435 let total = match std::fs::File::create(&path) {
437 Ok(mut file) => {
438 match output.write_canonical(&mut file, None) {
439 Ok(n) => n,
440 Err(e) => {
441 tracing::error!("output spill write failed: {}", e);
442 *result = ExecResult::failure(1, format!(
443 "output exceeded {} byte limit and spill to disk failed: {}", max, e
444 ));
445 return None;
446 }
447 }
448 }
449 Err(e) => {
450 tracing::error!("output spill file creation failed: {}", e);
451 *result = ExecResult::failure(1, format!(
452 "output exceeded {} byte limit and spill to disk failed: {}", max, e
453 ));
454 return None;
455 }
456 };
457
458 let head = read_head_from_file(&path, config.head_bytes).await.unwrap_or_default();
460 let tail = read_tail_from_file(&path, config.tail_bytes).await.unwrap_or_default();
461 let path_str = path.to_string_lossy();
462
463 result.set_out(format!(
464 "{}\n...\n{}\n[output truncated: {}]",
465 head, tail, spill_summary(total, ring_already_overflowed, &path_str)
466 ));
467 result.did_spill = true;
468
469 Some(SpillResult {
470 path,
471 total_bytes: total,
472 })
473}
474
475#[cfg(feature = "localfs")]
477async fn write_spill_file(data: &[u8]) -> Result<(PathBuf, usize), std::io::Error> {
478 let dir = paths::spill_dir();
479 tokio::fs::create_dir_all(&dir).await?;
480
481 let filename = generate_spill_filename();
482 let path = dir.join(filename);
483 tokio::fs::write(&path, data).await?;
484 Ok((path, data.len()))
485}
486
487#[cfg(feature = "localfs")]
499fn spill_summary(total: usize, ring_already_overflowed: bool, path: &str) -> String {
500 if ring_already_overflowed {
501 format!(
502 "{total} bytes captured — tail only at {path}; earlier output was dropped \
503 before the spill, so see the stderr overflow marker for the true size"
504 )
505 } else {
506 format!("{total} bytes total — full output at {path}")
507 }
508}
509
510#[cfg(feature = "localfs")]
512fn build_truncated_output(
513 full: &str,
514 config: &OutputLimitConfig,
515 spill_path: &std::path::Path,
516 total_bytes: usize,
517 ring_already_overflowed: bool,
518) -> String {
519 let head = truncate_to_char_boundary(full, config.head_bytes);
520 let tail = tail_from_str(full, config.tail_bytes);
521 let path_str = spill_path.to_string_lossy();
522 format!(
523 "{}\n...\n{}\n[output truncated: {}]",
524 head, tail, spill_summary(total_bytes, ring_already_overflowed, &path_str)
525 )
526}
527
528fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> &str {
530 if s.len() <= max_bytes {
531 return s;
532 }
533 let mut end = max_bytes;
535 while end > 0 && !s.is_char_boundary(end) {
536 end -= 1;
537 }
538 &s[..end]
539}
540
541fn tail_from_str(s: &str, max_bytes: usize) -> &str {
543 if s.len() <= max_bytes {
544 return s;
545 }
546 let start = s.len() - max_bytes;
547 let mut adjusted = start;
548 while adjusted < s.len() && !s.is_char_boundary(adjusted) {
549 adjusted += 1;
550 }
551 &s[adjusted..]
552}
553
554#[cfg(feature = "localfs")]
556async fn read_head_from_file(path: &std::path::Path, max_bytes: usize) -> Result<String, std::io::Error> {
557 use tokio::io::AsyncReadExt;
558
559 let mut file = tokio::fs::File::open(path).await?;
560 let mut buf = vec![0u8; max_bytes];
561 let n = file.read(&mut buf).await?;
562 buf.truncate(n);
563
564 let s = String::from_utf8_lossy(&buf);
565 let result = truncate_to_char_boundary(&s, max_bytes);
567 Ok(result.to_string())
568}
569
570#[cfg(feature = "localfs")]
572async fn read_tail_from_file(path: &std::path::Path, max_bytes: usize) -> Result<String, std::io::Error> {
573 use tokio::io::{AsyncReadExt, AsyncSeekExt};
574
575 let mut file = tokio::fs::File::open(path).await?;
576 let metadata = file.metadata().await?;
577 let len = metadata.len() as usize;
578
579 if len <= max_bytes {
580 let mut buf = Vec::new();
581 file.read_to_end(&mut buf).await?;
582 return Ok(String::from_utf8_lossy(&buf).into_owned());
583 }
584
585 let offset = len - max_bytes;
586 file.seek(std::io::SeekFrom::Start(offset as u64)).await?;
587 let mut buf = vec![0u8; max_bytes];
588 let n = file.read(&mut buf).await?;
589 buf.truncate(n);
590
591 let s = String::from_utf8_lossy(&buf);
593 Ok(s.into_owned())
594}
595
596#[cfg(feature = "localfs")]
598fn generate_spill_filename() -> String {
599 use std::sync::atomic::{AtomicUsize, Ordering};
600 use std::time::SystemTime;
601
602 static COUNTER: AtomicUsize = AtomicUsize::new(0);
603 let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
604 let ts = SystemTime::now()
605 .duration_since(SystemTime::UNIX_EPOCH)
606 .unwrap_or_default();
607 let pid = std::process::id();
608 format!("spill-{}.{}-{}-{}.txt", ts.as_secs(), ts.subsec_nanos(), pid, seq)
609}
610
611pub fn parse_size(s: &str) -> Result<usize, String> {
615 let s = s.trim();
616 if s.is_empty() {
617 return Err("empty size string".to_string());
618 }
619
620 let (num_str, multiplier) = if let Some(n) = s.strip_suffix('K').or_else(|| s.strip_suffix('k')) {
621 (n, 1024)
622 } else if let Some(n) = s.strip_suffix('M').or_else(|| s.strip_suffix('m')) {
623 (n, 1024 * 1024)
624 } else {
625 (s, 1)
626 };
627
628 let num: usize = num_str
629 .parse()
630 .map_err(|_| format!("invalid size: {}", s))?;
631
632 Ok(num * multiplier)
633}
634
635#[cfg(all(test, feature = "localfs"))]
636mod tests {
637 use super::*;
638
639 #[test]
640 fn test_none_is_disabled() {
641 let config = OutputLimitConfig::none();
642 assert!(!config.is_enabled());
643 assert_eq!(config.max_bytes(), None);
644 }
645
646 #[test]
647 fn test_agent_preset_is_enabled() {
648 let config = OutputLimitConfig::agent();
649 assert!(config.is_enabled());
650 assert_eq!(config.max_bytes(), Some(8 * 1024));
651 assert_eq!(config.head_bytes(), 1024);
652 assert_eq!(config.tail_bytes(), 512);
653 }
654
655 #[test]
656 fn test_set_limit() {
657 let mut config = OutputLimitConfig::none();
658 assert!(!config.is_enabled());
659
660 config.set_limit(Some(1024));
661 assert!(config.is_enabled());
662 assert_eq!(config.max_bytes(), Some(1024));
663
664 config.set_limit(None);
665 assert!(!config.is_enabled());
666 }
667
668 #[test]
669 fn test_set_head_tail() {
670 let mut config = OutputLimitConfig::agent();
671 config.set_head_bytes(2048);
672 config.set_tail_bytes(1024);
673 assert_eq!(config.head_bytes(), 2048);
674 assert_eq!(config.tail_bytes(), 1024);
675 }
676
677 #[test]
678 fn test_parse_size() {
679 assert_eq!(parse_size("64K").unwrap(), 64 * 1024);
680 assert_eq!(parse_size("64k").unwrap(), 64 * 1024);
681 assert_eq!(parse_size("1M").unwrap(), 1024 * 1024);
682 assert_eq!(parse_size("1m").unwrap(), 1024 * 1024);
683 assert_eq!(parse_size("65536").unwrap(), 65536);
684 assert!(parse_size("").is_err());
685 assert!(parse_size("abc").is_err());
686 }
687
688 #[test]
689 fn test_truncate_to_char_boundary() {
690 assert_eq!(truncate_to_char_boundary("hello", 10), "hello");
691 assert_eq!(truncate_to_char_boundary("hello", 3), "hel");
692 assert_eq!(truncate_to_char_boundary("日本語", 3), "日");
694 assert_eq!(truncate_to_char_boundary("日本語", 4), "日");
695 assert_eq!(truncate_to_char_boundary("日本語", 6), "日本");
696 }
697
698 #[test]
699 fn test_tail_from_str() {
700 assert_eq!(tail_from_str("hello", 10), "hello");
701 assert_eq!(tail_from_str("hello", 3), "llo");
702 assert_eq!(tail_from_str("日本語", 3), "語");
704 assert_eq!(tail_from_str("日本語", 6), "本語");
705 }
706
707 #[test]
708 fn test_generate_spill_filename() {
709 let name = generate_spill_filename();
710 assert!(name.starts_with("spill-"));
711 assert!(name.ends_with(".txt"));
712 }
713
714 #[tokio::test]
715 async fn test_spill_if_needed_under_limit() {
716 let config = OutputLimitConfig::agent();
717 let mut result = ExecResult::success("short output");
718 let spill = spill_if_needed(&mut result, &config).await;
719 assert!(spill.is_none());
720 assert_eq!(&*result.text_out(), "short output");
721 assert!(!result.did_spill);
722 }
723
724 #[tokio::test]
725 async fn test_spill_if_needed_over_limit() {
726 let config = OutputLimitConfig {
727 max_bytes: Some(100),
728 head_bytes: 20,
729 tail_bytes: 10,
730 spill_mode: SpillMode::Disk,
731 };
732 let big_output = "x".repeat(200);
733 let mut result = ExecResult::success(big_output);
734 let spill = spill_if_needed(&mut result, &config).await;
735 assert!(spill.is_some());
736 assert!(result.did_spill);
737
738 let spill = spill.unwrap();
739 assert_eq!(spill.total_bytes, 200);
740 assert!(spill.path.exists());
741
742 assert!(result.text_out().contains("..."));
744 assert!(result.text_out().contains("[output truncated: 200 bytes total"));
745 assert!(result.text_out().contains(&spill.path.to_string_lossy().to_string()));
746
747 assert!(result.text_out().starts_with(&"x".repeat(20)));
749
750 let spill_content = tokio::fs::read_to_string(&spill.path).await.unwrap();
752 assert_eq!(spill_content.len(), 200);
753
754 let _ = tokio::fs::remove_file(&spill.path).await;
756 }
757
758 #[tokio::test]
759 async fn test_spill_if_needed_disabled() {
760 let config = OutputLimitConfig::none();
761 let big_output = "x".repeat(200);
762 let mut result = ExecResult::success(big_output.clone());
763 let spill = spill_if_needed(&mut result, &config).await;
764 assert!(spill.is_none());
765 assert_eq!(&*result.text_out(), big_output);
766 assert!(!result.did_spill);
767 }
768
769 #[tokio::test]
775 async fn test_spill_if_needed_tunes_message_when_ring_already_overflowed() {
776 let config = OutputLimitConfig {
777 max_bytes: Some(100),
778 head_bytes: 20,
779 tail_bytes: 10,
780 spill_mode: SpillMode::Disk,
781 };
782 let big_output = "x".repeat(200);
783 let mut result = ExecResult::success(big_output).with_code(0);
784 result.did_spill = true;
786
787 let spill = spill_if_needed(&mut result, &config).await;
788 let spill = spill.expect("still over the limit, must spill");
789 assert!(
790 !result.text_out().contains("full output at"),
791 "must not claim the spill file is the full output when the ring \
792 already truncated it: {}",
793 result.text_out()
794 );
795 assert!(
799 !result.text_out().contains("bytes total"),
800 "the count is not the total after a ring overflow: {}",
801 result.text_out()
802 );
803 assert!(
804 result.text_out().contains("bytes captured — tail only at"),
805 "should name the tail-only nature of the spill file: {}",
806 result.text_out()
807 );
808 let _ = tokio::fs::remove_file(&spill.path).await;
809 }
810
811 #[tokio::test]
817 async fn apply_spill_contract_remaps_exit_code_when_spill_if_needed_flips_did_spill() {
818 let config = OutputLimitConfig {
821 max_bytes: Some(100),
822 head_bytes: 20,
823 tail_bytes: 10,
824 spill_mode: SpillMode::Memory,
825 };
826 let mut result = ExecResult::success("x".repeat(200));
827 apply_spill_contract(&mut result, &config).await;
828
829 assert!(result.did_spill);
830 assert_eq!(result.code, 3, "a spill must remap to exit 3");
831 assert_eq!(result.original_code, Some(0), "original exit code preserved");
832 }
833
834 #[tokio::test]
835 async fn apply_spill_contract_remaps_exit_code_when_did_spill_already_set_and_limit_disabled() {
836 let config = OutputLimitConfig::none();
841 let mut result = ExecResult::success("small").with_code(0);
842 result.did_spill = true;
843
844 apply_spill_contract(&mut result, &config).await;
845
846 assert_eq!(result.code, 3, "did_spill set anywhere must remap to exit 3");
847 assert_eq!(result.original_code, Some(0));
848 }
849
850 #[tokio::test]
851 async fn apply_spill_contract_is_idempotent_and_keeps_the_first_original_code() {
852 let config = OutputLimitConfig::none();
858 let mut result = ExecResult::success("spilled").with_code(7);
859 result.did_spill = true;
860
861 apply_spill_contract(&mut result, &config).await;
862 assert_eq!(result.code, 3);
863 assert_eq!(result.original_code, Some(7), "first pass captures the real code");
864
865 apply_spill_contract(&mut result, &config).await;
867 assert_eq!(result.code, 3, "still remapped");
868 assert_eq!(
869 result.original_code,
870 Some(7),
871 "a second pass must not overwrite the captured original with 3"
872 );
873 }
874
875 #[test]
876 fn test_build_truncated_output() {
877 let config = OutputLimitConfig {
878 max_bytes: Some(100),
879 head_bytes: 5,
880 tail_bytes: 3,
881 spill_mode: SpillMode::Disk,
882 };
883 let full = "abcdefghijklmnop";
884 let path = PathBuf::from("/tmp/test-spill.txt");
885 let result = build_truncated_output(full, &config, &path, 16, false);
886 assert!(result.starts_with("abcde"));
887 assert!(result.contains("..."));
888 assert!(result.contains("nop"));
889 assert!(result.contains("[output truncated: 16 bytes total — full output at /tmp/test-spill.txt]"));
890 }
891
892 #[test]
896 fn test_build_truncated_output_tunes_message_when_ring_already_overflowed() {
897 let config = OutputLimitConfig {
898 max_bytes: Some(100),
899 head_bytes: 5,
900 tail_bytes: 3,
901 spill_mode: SpillMode::Disk,
902 };
903 let full = "abcdefghijklmnop";
904 let path = PathBuf::from("/tmp/test-spill.txt");
905 let result = build_truncated_output(full, &config, &path, 16, true);
906 assert!(
907 !result.contains("full output at"),
908 "must not claim the spill file is the full output when the ring already \
909 truncated it: {result}"
910 );
911 assert!(
912 result.contains(
913 "[output truncated: 16 bytes captured — tail only at /tmp/test-spill.txt; \
914 earlier output was dropped before the spill, so see the stderr overflow \
915 marker for the true size]"
916 ),
917 "the count must read as captured-not-total and point at the true size: {result}"
918 );
919 }
920
921 #[tokio::test]
922 async fn test_kernel_agent_truncates_large_output() {
923 use crate::kernel::{Kernel, KernelConfig};
924
925 let config = KernelConfig::agent()
927 .with_output_limit(OutputLimitConfig {
928 max_bytes: Some(200),
929 head_bytes: 50,
930 tail_bytes: 30,
931 spill_mode: SpillMode::Disk,
932 });
933 let kernel = Kernel::new(config).expect("kernel creation");
934
935 let result = kernel.execute("seq 1 10000").await.expect("execute");
937 assert!(result.text_out().contains("[output truncated:"));
938 assert!(result.text_out().contains("full output at"));
939 assert!(result.text_out().starts_with("1\n"));
941 }
942
943 #[tokio::test]
952 async fn test_agent_kernel_unmodified_default_spills_to_disk() {
953 use crate::kernel::{Kernel, KernelConfig};
954
955 let config = KernelConfig::agent();
957 assert_eq!(config.output_limit.spill_mode(), SpillMode::Disk);
958 let kernel = Kernel::new(config).expect("kernel creation");
959
960 let big = "x".repeat(8 * 1024 + 200);
961 let result = kernel.execute(&format!("echo '{}'", big)).await.expect("execute");
962 assert_eq!(result.code, 3, "default 8K agent limit should trip the spill");
963 assert!(
964 result.text_out().contains("full output at"),
965 "an unmodified agent() default must spill to a real file, not truncate in \
966 memory: {}",
967 result.text_out()
968 );
969 }
970
971 #[tokio::test]
972 async fn test_spill_exits_3() {
973 use crate::kernel::{Kernel, KernelConfig};
974
975 let config = KernelConfig::agent()
976 .with_output_limit(OutputLimitConfig {
977 max_bytes: Some(100),
978 head_bytes: 30,
979 tail_bytes: 20,
980 spill_mode: SpillMode::Disk,
981 });
982 let kernel = Kernel::new(config).expect("kernel creation");
983
984 let big = "x".repeat(200);
985 let result = kernel.execute(&format!("echo '{}'", big)).await.expect("execute");
986 assert_eq!(result.code, 3, "spill should always exit 3");
987 assert_eq!(result.original_code, Some(0), "original command exit code preserved");
988 assert!(result.text_out().contains("[output truncated:"));
989 }
990
991 #[tokio::test]
992 async fn test_kernel_repl_no_truncation() {
993 use crate::kernel::{Kernel, KernelConfig};
994
995 let config = KernelConfig::repl();
997 let kernel = Kernel::new(config).expect("kernel creation");
998
999 let result = kernel.execute("seq 1 100").await.expect("execute");
1000 assert!(!result.text_out().contains("[output truncated:"));
1001 assert!(result.text_out().contains("100"));
1002 }
1003
1004 #[tokio::test]
1005 async fn test_kernel_builtin_truncation() {
1006 use crate::kernel::{Kernel, KernelConfig};
1007
1008 let config = KernelConfig::agent()
1010 .with_output_limit(OutputLimitConfig {
1011 max_bytes: Some(100),
1012 head_bytes: 30,
1013 tail_bytes: 20,
1014 spill_mode: SpillMode::Disk,
1015 });
1016 let kernel = Kernel::new(config).expect("kernel creation");
1017
1018 let big = "x".repeat(200);
1020 let result = kernel.execute(&format!("echo '{}'", big)).await.expect("execute");
1021 assert!(result.text_out().contains("[output truncated:"));
1022 }
1023
1024 #[test]
1027 fn test_estimated_byte_size_text() {
1028 use crate::interpreter::OutputData;
1029 let data = OutputData::text("hello world");
1030 assert_eq!(data.estimated_byte_size(), 11);
1031 }
1032
1033 #[test]
1034 fn test_estimated_byte_size_table() {
1035 use crate::interpreter::{OutputData, OutputNode};
1036 let data = OutputData::table(
1037 vec!["NAME".into(), "SIZE".into()],
1038 vec![
1039 OutputNode::new("foo").with_cells(vec!["123".into()]),
1040 OutputNode::new("bar").with_cells(vec!["456".into()]),
1041 ],
1042 );
1043 assert_eq!(data.estimated_byte_size(), 15);
1045 }
1046
1047 #[test]
1048 fn test_estimated_byte_size_tree() {
1049 use crate::interpreter::{OutputData, OutputNode};
1050 let data = OutputData::nodes(vec![
1051 OutputNode::new("src").with_children(vec![
1052 OutputNode::new("main.rs"),
1053 OutputNode::new("lib.rs"),
1054 ]),
1055 ]);
1056 assert_eq!(data.estimated_byte_size(), 20);
1058 }
1059
1060 #[test]
1061 fn test_write_canonical_matches_to_canonical_string() {
1062 use crate::interpreter::{OutputData, OutputNode};
1063
1064 let cases: Vec<OutputData> = vec![
1065 OutputData::text("hello world"),
1066 OutputData::nodes(vec![
1067 OutputNode::new("file1"),
1068 OutputNode::new("file2"),
1069 ]),
1070 OutputData::table(
1071 vec!["NAME".into(), "SIZE".into()],
1072 vec![
1073 OutputNode::new("foo").with_cells(vec!["123".into()]),
1074 OutputNode::new("bar").with_cells(vec!["456".into()]),
1075 ],
1076 ),
1077 OutputData::nodes(vec![
1078 OutputNode::new("src").with_children(vec![
1079 OutputNode::new("main.rs"),
1080 OutputNode::new("lib.rs"),
1081 ]),
1082 ]),
1083 ];
1084
1085 for data in cases {
1086 let expected = data.to_canonical_string();
1087 let mut buf = Vec::new();
1088 let written = data.write_canonical(&mut buf, None).unwrap();
1089 let got = String::from_utf8(buf).unwrap();
1090 assert_eq!(got, expected, "write_canonical mismatch for {:?}", data);
1091 assert_eq!(written, expected.len(), "byte count mismatch");
1092 }
1093 }
1094
1095 #[test]
1096 fn test_write_canonical_budget_stops_early() {
1097 use crate::interpreter::{OutputData, OutputNode};
1098
1099 let data = OutputData::nodes(
1100 (0..1000).map(|i| OutputNode::new(format!("file_{:04}", i))).collect()
1101 );
1102 let mut buf = Vec::new();
1103 let written = data.write_canonical(&mut buf, Some(100)).unwrap();
1104 assert!(written > 100, "should exceed budget slightly");
1106 assert!(written < 500, "should stop soon after budget: got {}", written);
1107 }
1108
1109 #[tokio::test]
1110 async fn test_spill_if_needed_large_output_data_no_oom() {
1111 use crate::interpreter::{OutputData, OutputNode};
1112
1113 let config = OutputLimitConfig {
1114 max_bytes: Some(1024),
1115 head_bytes: 100,
1116 tail_bytes: 50,
1117 spill_mode: SpillMode::Disk,
1118 };
1119
1120 let nodes: Vec<OutputNode> = (0..100_000)
1123 .map(|i| OutputNode::new(format!("node_{:06}", i)))
1124 .collect();
1125 let data = OutputData::nodes(nodes);
1126 let mut result = ExecResult::with_output(data);
1127
1128 let spill = spill_if_needed(&mut result, &config).await;
1129 assert!(spill.is_some(), "should have spilled");
1130 assert!(result.did_spill);
1131 assert!(result.text_out().contains("[output truncated:"));
1132
1133 if let Some(s) = spill {
1135 let _ = tokio::fs::remove_file(&s.path).await;
1136 }
1137 }
1138
1139 #[test]
1142 fn test_in_memory_builder_and_default() {
1143 assert_eq!(OutputLimitConfig::agent().spill_mode(), SpillMode::Disk);
1144 assert_eq!(OutputLimitConfig::agent().in_memory().spill_mode(), SpillMode::Memory);
1145
1146 let mut config = OutputLimitConfig::none();
1147 config.set_spill_mode(SpillMode::Memory);
1148 assert_eq!(config.spill_mode(), SpillMode::Memory);
1149 }
1150
1151 #[tokio::test]
1152 async fn test_memory_mode_truncates_string_without_disk() {
1153 let config = OutputLimitConfig {
1154 max_bytes: Some(100),
1155 head_bytes: 20,
1156 tail_bytes: 10,
1157 spill_mode: SpillMode::Memory,
1158 };
1159 let mut result = ExecResult::success("x".repeat(200));
1160 let spill = spill_if_needed(&mut result, &config).await;
1161
1162 assert!(spill.is_none(), "memory mode must not write a spill file");
1164 assert!(result.did_spill, "memory truncation must set did_spill for the exit-3 remap");
1165
1166 let out = result.text_out();
1167 assert!(out.contains("truncated in memory"), "got: {}", out);
1168 assert!(out.contains("200 bytes total"), "got: {}", out);
1169 assert!(!out.contains("full output at"), "memory mode must not point at a file: {}", out);
1170 assert!(out.starts_with(&"x".repeat(20)), "head preserved");
1171 }
1172
1173 #[tokio::test]
1174 async fn test_memory_mode_under_limit_untouched() {
1175 let config = OutputLimitConfig {
1176 max_bytes: Some(100),
1177 head_bytes: 20,
1178 tail_bytes: 10,
1179 spill_mode: SpillMode::Memory,
1180 };
1181 let mut result = ExecResult::success("short");
1182 let spill = spill_if_needed(&mut result, &config).await;
1183 assert!(spill.is_none());
1184 assert!(!result.did_spill);
1185 assert_eq!(&*result.text_out(), "short");
1186 }
1187
1188 #[tokio::test]
1189 async fn test_memory_mode_large_output_data_bounded() {
1190 use crate::interpreter::{OutputData, OutputNode};
1191
1192 let config = OutputLimitConfig {
1193 max_bytes: Some(1024),
1194 head_bytes: 100,
1195 tail_bytes: 50,
1196 spill_mode: SpillMode::Memory,
1197 };
1198
1199 let nodes: Vec<OutputNode> = (0..100_000)
1201 .map(|i| OutputNode::new(format!("node_{:06}", i)))
1202 .collect();
1203 let mut result = ExecResult::with_output(OutputData::nodes(nodes));
1204
1205 let spill = spill_if_needed(&mut result, &config).await;
1206 assert!(spill.is_none(), "memory mode writes no file");
1207 assert!(result.did_spill);
1208 let out = result.text_out();
1209 assert!(out.contains("truncated in memory"), "got: {}", out);
1210 assert!(out.starts_with("node_000000"), "head rendered: {}", out);
1211 assert!(out.contains("head only"), "got: {}", out);
1213 }
1214
1215 #[tokio::test]
1216 async fn test_kernel_memory_mode_exits_3_preserves_original() {
1217 use crate::kernel::{Kernel, KernelConfig};
1218
1219 let config = KernelConfig::agent().with_output_limit(OutputLimitConfig {
1220 max_bytes: Some(100),
1221 head_bytes: 30,
1222 tail_bytes: 20,
1223 spill_mode: SpillMode::Memory,
1224 });
1225 let kernel = Kernel::new(config).expect("kernel creation");
1226
1227 let big = "x".repeat(200);
1228 let result = kernel.execute(&format!("echo '{}'", big)).await.expect("execute");
1229 assert_eq!(result.code, 3, "memory truncation still signals via exit 3");
1230 assert_eq!(result.original_code, Some(0), "original exit code preserved");
1231 assert!(result.text_out().contains("truncated in memory"));
1232 assert!(!result.text_out().contains("full output at"));
1233 }
1234
1235 #[tokio::test]
1236 async fn test_nolocal_kernel_forces_memory_spill() {
1237 use crate::kernel::{Kernel, KernelConfig, VfsMountMode};
1238
1239 let config = KernelConfig::agent()
1243 .with_vfs_mode(VfsMountMode::NoLocal)
1244 .with_output_limit(OutputLimitConfig {
1245 max_bytes: Some(100),
1246 head_bytes: 30,
1247 tail_bytes: 20,
1248 spill_mode: SpillMode::Disk,
1249 });
1250 let kernel = Kernel::new(config).expect("kernel creation");
1251
1252 let big = "x".repeat(200);
1253 let result = kernel.execute(&format!("echo '{}'", big)).await.expect("execute");
1254 assert_eq!(result.code, 3, "still signals truncation via exit 3");
1255 assert!(result.text_out().contains("truncated in memory"), "got: {}", result.text_out());
1256 assert!(
1257 !result.text_out().contains("full output at"),
1258 "NoLocal kernel must not write a host spill file: {}",
1259 result.text_out()
1260 );
1261 }
1262
1263}