1use std::sync::Arc;
24
25use async_trait::async_trait;
26use serde_json::json;
27
28use super::{Capability, CapabilityLocalization, CapabilityStatus};
29use crate::atoms::PostToolExecHook;
30use crate::tool_output_sanitizer::{
31 output_verbosity_budget, priority_aware_truncate, resolve_auto_mode, truncate_exec_stream,
32};
33use crate::tool_types::{ToolCall, ToolDefinition, ToolResult};
34use crate::traits::{SessionFileSystem, ToolContext};
35use crate::typed_id::SessionId;
36
37const MAX_PERSISTED_STREAM_BYTES: usize = 1024 * 1024; const OUTPUT_DIR: &str = "/outputs";
40
41pub struct PersistResult {
43 pub stdout_path: Option<String>,
46 pub stderr_path: Option<String>,
49 pub stdout_total_lines: usize,
51}
52
53pub async fn persist_output(
60 file_store: &Arc<dyn SessionFileSystem>,
61 session_id: SessionId,
62 tool_call_id: &str,
63 stdout: &str,
64 stderr: &str,
65) -> Option<PersistResult> {
66 if stdout.is_empty() && stderr.is_empty() {
67 return None;
68 }
69
70 let safe_id: String = tool_call_id
71 .chars()
72 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
73 .collect();
74 if safe_id.is_empty() {
75 return None;
76 }
77
78 let mut result = PersistResult {
79 stdout_path: None,
80 stderr_path: None,
81 stdout_total_lines: 0,
82 };
83
84 if !stdout.is_empty() {
86 let stdout_to_persist = truncate_for_persistence(stdout, MAX_PERSISTED_STREAM_BYTES);
87 let path = format!("{OUTPUT_DIR}/{safe_id}.stdout");
88 result.stdout_total_lines = stdout.lines().count();
89 if file_store
90 .write_file(session_id, &path, &stdout_to_persist, "utf-8")
91 .await
92 .is_ok()
93 {
94 result.stdout_path = Some(path);
95 }
96 }
97
98 if !stderr.is_empty() {
100 let stderr_to_persist = truncate_for_persistence(stderr, MAX_PERSISTED_STREAM_BYTES);
101 let path = format!("{OUTPUT_DIR}/{safe_id}.stderr");
102 if file_store
103 .write_file(session_id, &path, &stderr_to_persist, "utf-8")
104 .await
105 .is_ok()
106 {
107 result.stderr_path = Some(path);
108 }
109 }
110
111 if result.stdout_path.is_some() || result.stderr_path.is_some() {
113 Some(result)
114 } else {
115 None
116 }
117}
118
119pub async fn persist_large_output(
123 file_store: &Arc<dyn SessionFileSystem>,
124 session_id: SessionId,
125 tool_call_id: &str,
126 stdout: &str,
127 stderr: &str,
128) -> Option<PersistResult> {
129 persist_output(file_store, session_id, tool_call_id, stdout, stderr).await
130}
131
132fn truncate_for_persistence(text: &str, max_bytes: usize) -> String {
134 if text.len() <= max_bytes {
135 return text.to_string();
136 }
137
138 let mut end = max_bytes;
140 while end > 0 && !text.is_char_boundary(end) {
141 end -= 1;
142 }
143 let mut truncated = text[..end].to_string();
144 truncated.push_str("\n\n[output truncated before persistence due to size limit]");
145 truncated
146}
147
148pub fn annotate_truncated_output(truncated: &str, file_path: &str, full_size: usize) -> String {
150 let size_kb = full_size / 1024;
151 format!(
152 "{truncated}\n\n[full output saved to {file_path} ({size_kb} KiB) — use read_file with offset/limit]"
153 )
154}
155
156pub fn compact_persisted_result_for_model(
166 obj: &mut serde_json::Map<String, serde_json::Value>,
167 stdout_path: &str,
168 stdout_full_size: usize,
169 output_mode: &str,
170) {
171 let exit_code = result_exit_code(obj);
172 let budget = output_verbosity_budget(resolve_auto_mode(output_mode, exit_code));
173
174 for field in ["stdout", "output"] {
176 if let Some(text) = obj.get(field).and_then(|v| v.as_str()) {
177 let compacted =
178 compact_with_annotation(text, budget, exit_code, stdout_path, stdout_full_size);
179 obj.insert(field.to_string(), json!(compacted));
180 break; }
182 }
183
184 compact_stderr_inline(obj, budget);
186}
187
188fn compact_stderr_inline(
194 obj: &mut serde_json::Map<String, serde_json::Value>,
195 budget: Option<usize>,
196) {
197 if let Some(budget) = budget
198 && let Some(stderr_text) = obj.get("stderr").and_then(|v| v.as_str())
199 && stderr_text.len() > budget
200 {
201 let compacted = priority_aware_truncate(stderr_text, budget);
202 obj.insert("stderr".to_string(), json!(compacted));
203 }
204}
205
206fn result_exit_code(obj: &serde_json::Map<String, serde_json::Value>) -> i32 {
208 if let Some(code) = obj.get("exit_code").and_then(|v| v.as_i64()) {
209 return if code == 0 { 0 } else { 1 };
210 }
211 if let Some(ok) = obj.get("success").and_then(|v| v.as_bool()) {
212 return if ok { 0 } else { 1 };
213 }
214 0
216}
217
218fn compact_with_annotation(
220 text: &str,
221 budget: Option<usize>,
222 exit_code: i32,
223 file_path: &str,
224 full_size: usize,
225) -> String {
226 let compacted = budget.filter(|budget| text.len() > *budget).map_or_else(
227 || text.to_string(),
228 |budget| truncate_exec_stream(text, budget, exit_code),
229 );
230 annotate_truncated_output(&compacted, file_path, full_size)
231}
232
233pub const TOOL_OUTPUT_PERSISTENCE_CAPABILITY_ID: &str = "tool_output_persistence";
234
235pub struct ToolOutputPersistenceCapability;
237
238impl Capability for ToolOutputPersistenceCapability {
239 fn id(&self) -> &str {
240 TOOL_OUTPUT_PERSISTENCE_CAPABILITY_ID
241 }
242
243 fn name(&self) -> &str {
244 "Tool Output Persistence"
245 }
246
247 fn description(&self) -> &str {
248 "Persists full exec tool output to session VFS before truncation, \
249 enabling lossless retrieval via read_file or grep."
250 }
251
252 fn localizations(&self) -> Vec<CapabilityLocalization> {
253 vec![CapabilityLocalization::text(
254 "uk",
255 "Збереження виводу інструментів",
256 "Зберігає повний вивід інструментів виконання у VFS сесії перед обрізанням, що дає змогу отримати його без втрат через read_file або grep.",
257 )]
258 }
259
260 fn status(&self) -> CapabilityStatus {
261 CapabilityStatus::Available
262 }
263
264 fn dependencies(&self) -> Vec<&'static str> {
265 vec!["session_file_system"]
266 }
267
268 fn post_tool_exec_hooks(&self) -> Vec<Arc<dyn PostToolExecHook>> {
269 vec![Arc::new(PersistOutputHook)]
270 }
271}
272
273pub struct PersistOutputHook;
275
276#[async_trait]
277impl PostToolExecHook for PersistOutputHook {
278 async fn after_exec(
279 &self,
280 tool_call: &ToolCall,
281 tool_def: &ToolDefinition,
282 result: &mut ToolResult,
283 context: &ToolContext,
284 ) {
285 if tool_def.hints().persist_output != Some(true) {
287 return;
288 }
289
290 if result
291 .result
292 .as_ref()
293 .and_then(|value| value.get("output_files"))
294 .is_some()
295 {
296 return;
297 }
298
299 let output_text = result.raw_output.take().unwrap_or_else(|| {
302 result
303 .result
304 .as_ref()
305 .map(extract_output_text)
306 .unwrap_or_default()
307 });
308 if output_text.is_empty() {
309 return;
310 }
311
312 let Some(ref file_store) = context.file_store else {
314 return;
315 };
316
317 let (stdout, stderr) = split_output_streams(&output_text);
319
320 if let Some(persist_result) = persist_large_output(
321 file_store,
322 context.session_id,
323 &tool_call.id,
324 stdout,
325 stderr,
326 )
327 .await
328 {
329 let output_mode = tool_call
330 .arguments
331 .get("output")
332 .and_then(|value| value.as_str())
333 .unwrap_or("auto");
334 if let Some(ref mut json_val) = result.result
336 && let Some(obj) = json_val.as_object_mut()
337 {
338 let mut output_files = Vec::new();
339
340 if let Some(ref path) = persist_result.stdout_path {
341 let display_path = file_store.display_path(path);
342 compact_persisted_result_for_model(
343 obj,
344 &display_path,
345 stdout.len(),
346 output_mode,
347 );
348
349 output_files.push(display_path.clone());
350 obj.insert("full_output".to_string(), json!(display_path));
353 obj.insert(
354 "total_lines".to_string(),
355 json!(persist_result.stdout_total_lines),
356 );
357 }
358
359 if let Some(ref path) = persist_result.stderr_path {
360 output_files.push(file_store.display_path(path));
361 if persist_result.stdout_path.is_none() {
364 let exit_code = result_exit_code(obj);
365 let budget =
366 output_verbosity_budget(resolve_auto_mode(output_mode, exit_code));
367 compact_stderr_inline(obj, budget);
368 }
369 }
370
371 if !output_files.is_empty() {
372 obj.insert("output_files".to_string(), json!(output_files));
373 }
374 }
375 }
376 }
377}
378
379fn split_output_streams(text: &str) -> (&str, &str) {
388 const STDERR_SEPARATOR: &str = "\n--- stderr ---\n";
389 if let Some(pos) = text.rfind(STDERR_SEPARATOR) {
390 (&text[..pos], &text[pos + STDERR_SEPARATOR.len()..])
391 } else {
392 (text, "")
393 }
394}
395
396fn extract_output_text(json: &serde_json::Value) -> String {
401 let mut parts = Vec::new();
402
403 if let Some(stdout) = json.get("stdout").and_then(|v| v.as_str())
404 && !stdout.is_empty()
405 {
406 parts.push(stdout);
407 }
408 if let Some(stderr) = json.get("stderr").and_then(|v| v.as_str())
409 && !stderr.is_empty()
410 {
411 if !parts.is_empty() {
412 parts.push("\n--- stderr ---\n");
413 }
414 parts.push(stderr);
415 }
416 if parts.is_empty()
418 && let Some(output) = json.get("output").and_then(|v| v.as_str())
419 && !output.is_empty()
420 {
421 parts.push(output);
422 }
423
424 parts.join("")
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use crate::tool_output_sanitizer::EXEC_OUTPUT_BUDGET;
431
432 #[test]
433 fn test_extract_output_text_stdout_stderr() {
434 let json = json!({
435 "stdout": "hello world",
436 "stderr": "warning: unused variable",
437 "exit_code": 0
438 });
439 let text = extract_output_text(&json);
440 assert!(text.contains("hello world"));
441 assert!(text.contains("warning: unused variable"));
442 assert!(text.contains("--- stderr ---"));
443 }
444
445 #[test]
446 fn test_extract_output_text_stdout_only() {
447 let json = json!({
448 "stdout": "hello",
449 "stderr": "",
450 "exit_code": 0
451 });
452 assert_eq!(extract_output_text(&json), "hello");
453 }
454
455 #[test]
456 fn test_extract_output_text_output_field() {
457 let json = json!({
458 "output": "combined output",
459 "exit_code": 0
460 });
461 assert_eq!(extract_output_text(&json), "combined output");
462 }
463
464 #[test]
465 fn test_extract_output_text_empty() {
466 let json = json!({ "exit_code": 0 });
467 assert_eq!(extract_output_text(&json), "");
468 }
469
470 #[test]
471 fn test_extract_output_text_prefers_stdout_over_output() {
472 let json = json!({
473 "stdout": "from stdout",
474 "output": "from output",
475 "exit_code": 0
476 });
477 let text = extract_output_text(&json);
479 assert!(text.contains("from stdout"));
480 assert!(!text.contains("from output"));
481 }
482
483 #[test]
484 fn test_capability_metadata() {
485 let cap = ToolOutputPersistenceCapability;
486 assert_eq!(cap.id(), "tool_output_persistence");
487 assert!(!cap.post_tool_exec_hooks().is_empty());
488 assert!(cap.dependencies().contains(&"session_file_system"));
489 }
490
491 #[test]
492 fn test_split_output_streams_both() {
493 let text = "hello world\n--- stderr ---\nwarning: unused";
494 let (stdout, stderr) = split_output_streams(text);
495 assert_eq!(stdout, "hello world");
496 assert_eq!(stderr, "warning: unused");
497 }
498
499 #[test]
500 fn test_split_output_streams_stdout_only() {
501 let text = "hello world\nline two";
502 let (stdout, stderr) = split_output_streams(text);
503 assert_eq!(stdout, "hello world\nline two");
504 assert!(stderr.is_empty());
505 }
506
507 #[test]
508 fn test_split_output_streams_empty() {
509 let (stdout, stderr) = split_output_streams("");
510 assert!(stdout.is_empty());
511 assert!(stderr.is_empty());
512 }
513
514 #[test]
515 fn test_annotate_truncated_output() {
516 let annotated = annotate_truncated_output(
517 "truncated text...",
518 "/workspace/outputs/abc.stdout",
519 50 * 1024,
520 );
521 assert!(annotated.contains("truncated text..."));
522 assert!(annotated.contains("/workspace/outputs/abc.stdout"));
523 assert!(annotated.contains("50 KiB"));
524 assert!(annotated.contains("read_file"));
525 }
526
527 #[test]
528 fn test_annotate_truncated_output_small() {
529 let annotated = annotate_truncated_output("short", "/workspace/outputs/x.stdout", 1024);
530 assert!(annotated.contains("1 KiB"));
531 assert!(annotated.contains("read_file with offset/limit"));
532 }
533
534 #[test]
535 fn test_truncate_for_persistence_noop_when_under_limit() {
536 let text = "hello";
537 assert_eq!(truncate_for_persistence(text, 1024), text);
538 }
539
540 #[test]
541 fn test_truncate_for_persistence_adds_marker_when_over_limit() {
542 let text = "x".repeat(128);
543 let out = truncate_for_persistence(&text, 32);
544 assert!(out.len() > 32);
545 assert!(out.starts_with(&"x".repeat(32)));
546 assert!(out.contains("output truncated before persistence"));
547 }
548
549 use crate::error::Result;
552 use crate::session_file::{FileInfo, FileStat, GrepMatch, SessionFile};
553 use crate::traits::SessionFileSystem;
554 use chrono::Utc;
555 use std::collections::HashMap;
556 use std::sync::Mutex;
557 use uuid::Uuid;
558
559 #[derive(Default)]
560 struct MockFileStore {
561 files: Mutex<HashMap<String, String>>,
562 }
563
564 impl MockFileStore {
565 fn content(&self, path: &str) -> Option<String> {
566 self.files.lock().unwrap().get(path).cloned()
567 }
568 }
569
570 #[async_trait]
571 impl SessionFileSystem for MockFileStore {
572 fn is_mount_resolver(&self) -> bool {
573 false
574 }
575
576 async fn read_file(
577 &self,
578 _session_id: SessionId,
579 _path: &str,
580 ) -> Result<Option<SessionFile>> {
581 Ok(None)
582 }
583
584 async fn write_file(
585 &self,
586 _session_id: SessionId,
587 path: &str,
588 content: &str,
589 _encoding: &str,
590 ) -> Result<SessionFile> {
591 self.files
592 .lock()
593 .unwrap()
594 .insert(path.to_string(), content.to_string());
595 Ok(SessionFile {
596 id: Uuid::new_v4(),
597 session_id: Uuid::nil(),
598 path: path.to_string(),
599 name: path.rsplit('/').next().unwrap_or("").to_string(),
600 content: Some(content.to_string()),
601 encoding: "utf-8".to_string(),
602 is_directory: false,
603 is_readonly: false,
604 size_bytes: content.len() as i64,
605 created_at: Utc::now(),
606 updated_at: Utc::now(),
607 })
608 }
609
610 async fn delete_file(
611 &self,
612 _session_id: SessionId,
613 _path: &str,
614 _recursive: bool,
615 ) -> Result<bool> {
616 Ok(false)
617 }
618
619 async fn list_directory(
620 &self,
621 _session_id: SessionId,
622 _path: &str,
623 ) -> Result<Vec<FileInfo>> {
624 Ok(vec![])
625 }
626
627 async fn stat_file(&self, _session_id: SessionId, _path: &str) -> Result<Option<FileStat>> {
628 Ok(None)
629 }
630
631 async fn grep_files(
632 &self,
633 _session_id: SessionId,
634 _pattern: &str,
635 _path_pattern: Option<&str>,
636 ) -> Result<Vec<GrepMatch>> {
637 Ok(vec![])
638 }
639
640 async fn create_directory(&self, _session_id: SessionId, _path: &str) -> Result<FileInfo> {
641 Err(anyhow::anyhow!("not implemented").into())
642 }
643 }
644
645 fn test_session_id() -> SessionId {
646 SessionId::from(Uuid::nil())
647 }
648
649 fn persistence_tool_def() -> ToolDefinition {
650 use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolHints, ToolPolicy};
651
652 ToolDefinition::Builtin(BuiltinTool {
653 name: "bash".to_string(),
654 display_name: None,
655 description: "execute a command".to_string(),
656 parameters: json!({}),
657 policy: ToolPolicy::Auto,
658 category: None,
659 deferrable: DeferrablePolicy::default(),
660 hints: ToolHints::default().with_persist_output(true),
661 full_parameters: None,
662 })
663 }
664
665 #[tokio::test]
666 async fn test_hook_honors_explicit_normal_and_persists_losslessly() {
667 let mut lines = vec!["src/runtime.rs:10: first relevant match".to_string()];
668 lines.extend((0..1200).map(|i| format!("src/module_{i}.rs: ordinary source line")));
669 lines.extend((0..300).map(|i| format!("src/errors_{i}.rs: struct ErrorContext{i}")));
670 let full_output = lines.join("\n");
671 let visible_output =
672 crate::tool_output_sanitizer::truncate_exec_stream(&full_output, NORMAL_BUDGET, 0);
673 let store = Arc::new(MockFileStore::default());
674 let mut context = ToolContext::new(test_session_id());
675 context.file_store = Some(store.clone());
676 let tool_call = ToolCall {
677 id: "call-normal".to_string(),
678 name: "bash".to_string(),
679 arguments: json!({"command": "git grep Error", "output": "normal"}),
680 };
681 let mut result = ToolResult {
682 tool_call_id: tool_call.id.clone(),
683 result: Some(json!({
684 "stdout": visible_output,
685 "stderr": "",
686 "exit_code": 0,
687 "success": true,
688 })),
689 images: None,
690 error: None,
691 connection_required: None,
692 raw_output: Some(full_output.clone()),
693 };
694
695 PersistOutputHook
696 .after_exec(&tool_call, &persistence_tool_def(), &mut result, &context)
697 .await;
698
699 let inline = result.result.as_ref().unwrap()["stdout"].as_str().unwrap();
700 assert!(inline.len() > AUTO_SUCCESS_BUDGET + 200);
701 assert!(inline.len() <= NORMAL_BUDGET + 200);
702 assert!(inline.contains("first relevant match"));
703 assert_eq!(
704 store.content("/outputs/call-normal.stdout").as_deref(),
705 Some(full_output.as_str())
706 );
707 }
708
709 #[tokio::test]
710 async fn test_persist_large_output_persists_stdout_below_budget() {
711 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::default());
712 let small = "a".repeat(EXEC_OUTPUT_BUDGET);
713 let result = persist_large_output(&store, test_session_id(), "call-1", &small, "").await;
714 let r = result.expect("should persist non-empty stdout");
715 assert_eq!(r.stdout_path.as_deref(), Some("/outputs/call-1.stdout"));
716 }
717
718 #[tokio::test]
719 async fn test_persist_large_output_persists_stdout_above_budget() {
720 let mock = Arc::new(MockFileStore::default());
721 let store: Arc<dyn SessionFileSystem> = mock.clone();
722 let large = "x".repeat(EXEC_OUTPUT_BUDGET + 1);
723 let result = persist_large_output(&store, test_session_id(), "call-2", &large, "").await;
724 let r = result.expect("should persist");
725 assert!(r.stdout_path.is_some());
726 assert_eq!(r.stdout_path.as_deref(), Some("/outputs/call-2.stdout"));
727 assert!(r.stderr_path.is_none());
728 assert_eq!(r.stdout_total_lines, 1);
729 assert_eq!(
731 mock.content("/outputs/call-2.stdout").unwrap().len(),
732 large.len()
733 );
734 }
735
736 #[tokio::test]
737 async fn test_persist_large_output_caps_persisted_stdout_size() {
738 let mock = Arc::new(MockFileStore::default());
739 let store: Arc<dyn SessionFileSystem> = mock.clone();
740 let huge = "x".repeat(MAX_PERSISTED_STREAM_BYTES + 2048);
741 let result = persist_large_output(&store, test_session_id(), "call-cap", &huge, "").await;
742 assert!(result.is_some());
743 let persisted = mock.content("/outputs/call-cap.stdout").unwrap();
744 assert!(persisted.len() < huge.len());
745 assert!(persisted.contains("output truncated before persistence"));
746 }
747
748 #[tokio::test]
749 async fn test_persist_large_output_persists_stderr_above_threshold() {
750 let mock = Arc::new(MockFileStore::default());
751 let store: Arc<dyn SessionFileSystem> = mock.clone();
752 let large_stderr = "e".repeat(4097);
753 let result =
754 persist_large_output(&store, test_session_id(), "call-3", "small", &large_stderr).await;
755 let r = result.expect("should persist stderr");
756 assert_eq!(r.stdout_path.as_deref(), Some("/outputs/call-3.stdout"));
757 assert_eq!(r.stderr_path.as_deref(), Some("/outputs/call-3.stderr"));
758 assert_eq!(mock.content("/outputs/call-3.stderr").unwrap().len(), 4097);
759 }
760
761 #[tokio::test]
762 async fn test_persist_large_output_both_stdout_and_stderr() {
763 let mock = Arc::new(MockFileStore::default());
764 let store: Arc<dyn SessionFileSystem> = mock.clone();
765 let large_stdout = "o".repeat(EXEC_OUTPUT_BUDGET + 100);
766 let large_stderr = "e".repeat(5000);
767 let result = persist_large_output(
768 &store,
769 test_session_id(),
770 "call-4",
771 &large_stdout,
772 &large_stderr,
773 )
774 .await;
775 let r = result.expect("should persist both");
776 assert!(r.stdout_path.is_some());
777 assert!(r.stderr_path.is_some());
778 }
779
780 #[tokio::test]
781 async fn test_persist_large_output_sanitizes_id() {
782 let mock = Arc::new(MockFileStore::default());
783 let store: Arc<dyn SessionFileSystem> = mock.clone();
784 let large = "x".repeat(EXEC_OUTPUT_BUDGET + 1);
785 let result =
786 persist_large_output(&store, test_session_id(), "call/../../../etc", &large, "").await;
787 let r = result.expect("should persist with sanitized id");
788 assert_eq!(r.stdout_path.as_deref(), Some("/outputs/calletc.stdout"));
790 }
791
792 #[tokio::test]
793 async fn test_persist_large_output_empty_id_returns_none() {
794 let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::default());
795 let large = "x".repeat(EXEC_OUTPUT_BUDGET + 1);
796 let result = persist_large_output(&store, test_session_id(), "///...", &large, "").await;
797 assert!(result.is_none());
798 }
799
800 #[tokio::test]
801 async fn test_persist_large_output_line_count() {
802 let mock = Arc::new(MockFileStore::default());
803 let store: Arc<dyn SessionFileSystem> = mock.clone();
804 let line = "x".repeat(200);
806 let lines: Vec<&str> = std::iter::repeat_n(line.as_str(), 100).collect();
807 let large = lines.join("\n");
808 assert!(large.len() > EXEC_OUTPUT_BUDGET);
809 let result =
810 persist_large_output(&store, test_session_id(), "call-lines", &large, "").await;
811 let r = result.expect("should persist");
812 assert_eq!(r.stdout_total_lines, 100);
813 }
814
815 use crate::tool_output_sanitizer::{AUTO_SUCCESS_BUDGET, NORMAL_BUDGET};
819
820 #[test]
821 fn test_compact_success_caps_stdout_to_auto_success_budget() {
822 let large_stdout = "x".repeat(NORMAL_BUDGET + 1);
823 let large_stdout_len = large_stdout.len();
824 let mut obj = serde_json::Map::new();
825 obj.insert("stdout".to_string(), json!(large_stdout));
826 obj.insert("exit_code".to_string(), json!(0));
827
828 compact_persisted_result_for_model(
829 &mut obj,
830 "/outputs/call.stdout",
831 large_stdout_len,
832 "auto",
833 );
834
835 let inline = obj["stdout"].as_str().unwrap();
836 assert!(
838 inline.len() <= AUTO_SUCCESS_BUDGET + 200,
839 "inline stdout ({} bytes) should be ≤ AUTO_SUCCESS_BUDGET + pointer",
840 inline.len()
841 );
842 assert!(
843 inline.contains("full output saved to"),
844 "must have file pointer"
845 );
846 }
847
848 #[test]
849 fn test_compact_failure_caps_stdout_to_normal_budget() {
850 let large_stdout = "y".repeat(NORMAL_BUDGET * 2);
851 let large_stdout_len = large_stdout.len();
852 let mut obj = serde_json::Map::new();
853 obj.insert("stdout".to_string(), json!(large_stdout));
854 obj.insert("exit_code".to_string(), json!(1));
855
856 compact_persisted_result_for_model(
857 &mut obj,
858 "/outputs/call.stdout",
859 large_stdout_len,
860 "auto",
861 );
862
863 let inline = obj["stdout"].as_str().unwrap();
864 assert!(
865 inline.len() <= NORMAL_BUDGET + 200,
866 "failure stdout ({} bytes) should be ≤ NORMAL_BUDGET + pointer",
867 inline.len()
868 );
869 assert!(
870 inline.contains("full output saved to"),
871 "must have file pointer"
872 );
873 }
874
875 #[test]
876 fn test_compact_small_stdout_unchanged_except_annotation() {
877 let small = "hello world";
878 let mut obj = serde_json::Map::new();
879 obj.insert("stdout".to_string(), json!(small));
880 obj.insert("exit_code".to_string(), json!(0));
881
882 compact_persisted_result_for_model(&mut obj, "/outputs/call.stdout", small.len(), "auto");
883
884 let inline = obj["stdout"].as_str().unwrap();
885 assert!(
886 inline.starts_with("hello world"),
887 "small content must be preserved"
888 );
889 assert!(
890 inline.contains("full output saved to"),
891 "must have file pointer"
892 );
893 }
894
895 #[test]
896 fn test_compact_oversized_full_mode_stays_full() {
897 let large = "z".repeat(NORMAL_BUDGET * 3);
898 let large_len = large.len();
899 let mut obj = serde_json::Map::new();
900 obj.insert("stdout".to_string(), json!(large.clone()));
901 obj.insert("exit_code".to_string(), json!(0));
902
903 compact_persisted_result_for_model(&mut obj, "/outputs/call.stdout", large_len, "full");
904
905 let inline = obj["stdout"].as_str().unwrap();
906 assert!(inline.starts_with(&large));
907 assert!(inline.contains("full output saved to"));
908 }
909
910 #[test]
911 fn test_compact_explicit_normal_preserves_leading_search_matches() {
912 let mut lines = vec![
913 "src/runtime.rs:10: query_history registration".to_string(),
914 "src/runtime.rs:11: history capability".to_string(),
915 ];
916 lines.extend((0..1200).map(|i| format!("src/module_{i}.rs: ordinary source line")));
917 lines.extend((0..300).map(|i| format!("src/errors_{i}.rs: struct ErrorContext{i}")));
918 let output = lines.join("\n");
919 let mut obj = serde_json::Map::new();
920 obj.insert("stdout".to_string(), json!(output.clone()));
921 obj.insert("exit_code".to_string(), json!(0));
922
923 compact_persisted_result_for_model(
924 &mut obj,
925 "/outputs/call.stdout",
926 output.len(),
927 "normal",
928 );
929
930 let inline = obj["stdout"].as_str().unwrap();
931 assert!(inline.len() > AUTO_SUCCESS_BUDGET + 200);
932 assert!(inline.len() <= NORMAL_BUDGET + 200);
933 assert!(inline.contains("query_history registration"));
934 assert!(inline.contains("history capability"));
935 assert!(inline.contains("full output saved to"));
936 }
937
938 #[test]
939 fn test_compact_large_stderr_capped_on_failure() {
940 let large_stderr = "e".repeat(NORMAL_BUDGET * 2);
941 let large_stdout = "o".repeat(100);
942 let large_stdout_len = large_stdout.len();
943 let mut obj = serde_json::Map::new();
944 obj.insert("stdout".to_string(), json!(large_stdout));
945 obj.insert("stderr".to_string(), json!(large_stderr));
946 obj.insert("exit_code".to_string(), json!(1));
947
948 compact_persisted_result_for_model(
949 &mut obj,
950 "/outputs/call.stdout",
951 large_stdout_len,
952 "auto",
953 );
954
955 let inline_stderr = obj["stderr"].as_str().unwrap();
956 assert!(
957 inline_stderr.len() <= NORMAL_BUDGET + 200,
958 "stderr ({} bytes) must be capped to NORMAL_BUDGET",
959 inline_stderr.len()
960 );
961 }
962
963 #[test]
964 fn test_compact_non_exec_output_field() {
965 let large_output = "d".repeat(NORMAL_BUDGET + 1);
967 let large_output_len = large_output.len();
968 let mut obj = serde_json::Map::new();
969 obj.insert("output".to_string(), json!(large_output));
970 obj.insert("exit_code".to_string(), json!(0));
971
972 compact_persisted_result_for_model(
973 &mut obj,
974 "/outputs/call.stdout",
975 large_output_len,
976 "auto",
977 );
978
979 let inline = obj["output"].as_str().unwrap();
980 assert!(
981 inline.len() <= AUTO_SUCCESS_BUDGET + 200,
982 "output field must be compacted"
983 );
984 assert!(inline.contains("full output saved to"));
985 }
986
987 #[test]
988 fn test_compact_no_file_store_preserves_current_behavior() {
989 let mut obj = serde_json::Map::new();
991 obj.insert("result".to_string(), json!("ok"));
992 obj.insert("exit_code".to_string(), json!(0));
993
994 compact_persisted_result_for_model(&mut obj, "/outputs/call.stdout", 0, "auto");
995
996 assert_eq!(obj.get("stdout"), None);
998 assert_eq!(obj.get("output"), None);
999 assert_eq!(obj["result"].as_str(), Some("ok"));
1000 }
1001
1002 #[test]
1003 fn test_compact_stderr_inline_caps_stderr() {
1004 let large = "e".repeat(NORMAL_BUDGET * 2);
1007 let mut obj = serde_json::Map::new();
1008 obj.insert("stderr".to_string(), json!(large));
1009
1010 compact_stderr_inline(&mut obj, Some(NORMAL_BUDGET));
1011
1012 let inline = obj["stderr"].as_str().unwrap();
1013 assert!(
1014 inline.len() <= NORMAL_BUDGET + 200,
1015 "compact_stderr_inline must cap stderr to budget ({} bytes got)",
1016 inline.len()
1017 );
1018 }
1019}