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