| 1 | //! Tool-output spillover writer (#422). |
| 2 | //! |
| 3 | //! When a tool produces output that's too large to land in the model's |
| 4 | //! context budget, we want two things at once: |
| 5 | //! |
| 6 | //! 1. The transcript / tool-cell renders a bounded preview so the UI |
| 7 | //! stays scannable. |
| 8 | //! 2. The full original output is preserved on disk so the model can |
| 9 | //! `read_file` it back if it later needs the elided tail, and so |
| 10 | //! the user can open it in `$EDITOR`. |
| 11 | //! |
| 12 | //! This module owns the disk side. Files land in |
| 13 | //! `~/.deepseek/tool_outputs/<sanitised-id>.txt`. The id is the tool |
| 14 | //! call id the engine assigns; we sanitise it conservatively (ASCII |
| 15 | //! alphanumeric + `-`/`_`) so a hostile id can't escape the directory |
| 16 | //! via `..` or absolute-path tricks. |
| 17 | //! |
| 18 | //! Boot prune drops files whose mtime is older than [`SPILLOVER_MAX_AGE`] |
| 19 | //! (7 days). Prune failures are logged and never fatal — the user |
| 20 | //! shouldn't see startup wedge because of a stale tool-output file. |
| 21 | //! |
| 22 | //! ## Live callers |
| 23 | //! |
| 24 | //! * [`apply_spillover`] — invoked from the engine's tool-execution |
| 25 | //! path (`turn_loop.rs`) so any successful tool result over |
| 26 | //! [`SPILLOVER_THRESHOLD_BYTES`] spills to disk and the model |
| 27 | //! receives a [`SPILLOVER_HEAD_BYTES`] head plus a pointer footer. |
| 28 | //! * Boot prune in `main.rs` deletes files older than |
| 29 | //! [`SPILLOVER_MAX_AGE`]. |
| 30 | //! |
| 31 | //! UI-side rendering of the inline `full output: <path>` annotation |
| 32 | //! is owned by `tui/history.rs::render_spillover_annotation`. The |
| 33 | //! tool-details pager opens the spillover file when the user |
| 34 | //! presses `Alt+V` (or plain `v` with empty composer) on a spilled |
| 35 | //! tool cell. |
| 36 | |
| 37 | use std::fs; |
| 38 | use std::io; |
| 39 | use std::path::PathBuf; |
| 40 | use std::time::{Duration, SystemTime}; |
| 41 | |
| 42 | use crate::tools::spec::ToolResult; |
| 43 | |
| 44 | // `Path` is only referenced from helpers gated to test builds. |
| 45 | #[cfg(test)] |
| 46 | use std::path::Path; |
| 47 | |
| 48 | /// Name of the spillover directory under `~/.deepseek/`. |
| 49 | pub const SPILLOVER_DIR_NAME: &str = "tool_outputs"; |
| 50 | |
| 51 | /// Default threshold above which a tool result is a candidate for |
| 52 | /// spillover. Mirrors the `MAX_MEMORY_SIZE` ceiling we use elsewhere |
| 53 | /// for "too large to inline" so the rules feel consistent. Wired |
| 54 | /// callers can pass a different value if a tool family has different |
| 55 | /// economics. |
| 56 | pub const SPILLOVER_THRESHOLD_BYTES: usize = 100 * 1024; // 100 KiB |
| 57 | |
| 58 | /// Default boot-prune age. Older spillover files are deleted on |
| 59 | /// startup to keep `~/.deepseek/tool_outputs/` from growing without |
| 60 | /// bound. Mirrors the workspace-snapshot 7-day default. |
| 61 | pub const SPILLOVER_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60); |
| 62 | |
| 63 | /// Resolve `~/.deepseek/tool_outputs/`. Returns `None` if the home |
| 64 | /// directory can't be determined (CI containers occasionally hit |
| 65 | /// this). Callers should treat `None` as "spillover unavailable" and |
| 66 | /// degrade gracefully rather than fail the tool call. |
| 67 | #[must_use] |
| 68 | pub fn spillover_root() -> Option<PathBuf> { |
| 69 | Some(dirs::home_dir()?.join(".deepseek").join(SPILLOVER_DIR_NAME)) |
| 70 | } |
| 71 | |
| 72 | /// Resolve the spillover-file path for a tool call id. Sanitises the |
| 73 | /// id so that a hostile value can't escape the storage directory. |
| 74 | /// Returns `None` for empty / fully-invalid ids; the caller should |
| 75 | /// treat that as "spillover unavailable" and skip the write. |
| 76 | #[must_use] |
| 77 | pub fn spillover_path(id: &str) -> Option<PathBuf> { |
| 78 | let sanitised = sanitise_id(id)?; |
| 79 | Some(spillover_root()?.join(format!("{sanitised}.txt"))) |
| 80 | } |
| 81 | |
| 82 | /// Write `content` to the spillover file for `id`. Creates the |
| 83 | /// parent directory if needed. Returns the resolved path on success. |
| 84 | /// |
| 85 | /// Atomic via `write` + filesystem rename guarantees from the |
| 86 | /// underlying OS — the file is created at a temp name first and |
| 87 | /// then renamed into place. Failures bubble up as `io::Error` so the |
| 88 | /// caller can decide whether to surface them. |
| 89 | pub fn write_spillover(id: &str, content: &str) -> io::Result<PathBuf> { |
| 90 | let path = spillover_path(id).ok_or_else(|| { |
| 91 | io::Error::new( |
| 92 | io::ErrorKind::InvalidInput, |
| 93 | "could not resolve spillover path (empty/invalid id or missing home directory)", |
| 94 | ) |
| 95 | })?; |
| 96 | if let Some(parent) = path.parent() { |
| 97 | fs::create_dir_all(parent)?; |
| 98 | } |
| 99 | crate::utils::write_atomic(&path, content.as_bytes())?; |
| 100 | Ok(path) |
| 101 | } |
| 102 | |
| 103 | /// Drop spillover files older than `max_age`. Returns the number of |
| 104 | /// files removed. Non-fatal: directory-missing returns 0; per-file |
| 105 | /// errors are logged and skipped. Mirrors |
| 106 | /// [`crate::session_manager::prune_workspace_snapshots`]. |
| 107 | pub fn prune_older_than(max_age: Duration) -> io::Result<usize> { |
| 108 | let Some(root) = spillover_root() else { |
| 109 | return Ok(0); |
| 110 | }; |
| 111 | if !root.exists() { |
| 112 | return Ok(0); |
| 113 | } |
| 114 | let cutoff = SystemTime::now() |
| 115 | .checked_sub(max_age) |
| 116 | .unwrap_or(SystemTime::UNIX_EPOCH); |
| 117 | let mut pruned = 0usize; |
| 118 | for entry in fs::read_dir(&root)? { |
| 119 | let entry = match entry { |
| 120 | Ok(e) => e, |
| 121 | Err(err) => { |
| 122 | tracing::warn!(target: "spillover", ?err, "skipping unreadable dir entry"); |
| 123 | continue; |
| 124 | } |
| 125 | }; |
| 126 | let path = entry.path(); |
| 127 | if !path.is_file() { |
| 128 | continue; |
| 129 | } |
| 130 | let modified = match entry.metadata().and_then(|m| m.modified()) { |
| 131 | Ok(t) => t, |
| 132 | Err(err) => { |
| 133 | tracing::warn!(target: "spillover", ?err, ?path, "skipping unreadable mtime"); |
| 134 | continue; |
| 135 | } |
| 136 | }; |
| 137 | if modified < cutoff { |
| 138 | if let Err(err) = fs::remove_file(&path) { |
| 139 | tracing::warn!(target: "spillover", ?err, ?path, "spillover prune skipped a file"); |
| 140 | continue; |
| 141 | } |
| 142 | pruned += 1; |
| 143 | } |
| 144 | } |
| 145 | Ok(pruned) |
| 146 | } |
| 147 | |
| 148 | /// Convenience for the common "too long? spill it." pattern. If |
| 149 | /// `content` is at or below `threshold` bytes, returns `None` and the |
| 150 | /// caller keeps the inline content. Above the threshold, writes the |
| 151 | /// full content to the spillover file and returns |
| 152 | /// `Some((head, path))` where `head` is the leading slice the caller |
| 153 | /// can show inline. The trailing tail isn't returned — `path` is the |
| 154 | /// canonical reference. |
| 155 | /// |
| 156 | /// `head_bytes` controls how much inline content the caller wants to |
| 157 | /// keep. Pass `threshold` for "preserve as much as fits inline" or |
| 158 | /// a smaller value (e.g. `4 * 1024`) for "show a peek". |
| 159 | pub fn maybe_spillover( |
| 160 | id: &str, |
| 161 | content: &str, |
| 162 | threshold: usize, |
| 163 | head_bytes: usize, |
| 164 | ) -> io::Result<Option<(String, PathBuf)>> { |
| 165 | if content.len() <= threshold { |
| 166 | return Ok(None); |
| 167 | } |
| 168 | let path = write_spillover(id, content)?; |
| 169 | // Don't slice mid-utf8: walk back to a char boundary if needed. |
| 170 | let cut = head_bytes.min(content.len()); |
| 171 | let cut = (0..=cut) |
| 172 | .rev() |
| 173 | .find(|&i| content.is_char_boundary(i)) |
| 174 | .unwrap_or(0); |
| 175 | Ok(Some((content[..cut].to_string(), path))) |
| 176 | } |
| 177 | |
| 178 | /// Inline head retained when [`apply_spillover`] truncates a tool |
| 179 | /// result. 32 KiB is large enough for the model to keep meaningful |
| 180 | /// context (a long stack trace, a `git diff` head, a directory |
| 181 | /// listing of typical depth) without consuming the lion's share of |
| 182 | /// the per-turn context budget. The full output is preserved on |
| 183 | /// disk; the model can `read_file` it back if it needs the tail. |
| 184 | pub const SPILLOVER_HEAD_BYTES: usize = 32 * 1024; |
| 185 | |
| 186 | /// Apply spillover to a tool result in place. If the result's |
| 187 | /// content exceeds [`SPILLOVER_THRESHOLD_BYTES`], writes the full |
| 188 | /// content to a sibling file under `~/.deepseek/tool_outputs/`, |
| 189 | /// replaces `result.content` with a [`SPILLOVER_HEAD_BYTES`] head |
| 190 | /// plus a footer pointing the model at the spillover file, and |
| 191 | /// stamps `metadata.spillover_path` so the UI can render its |
| 192 | /// "full output: …" annotation. |
| 193 | /// |
| 194 | /// Returns the spillover path on success, `None` if no spillover |
| 195 | /// happened (content small enough, error result, write failure). |
| 196 | /// Failures are logged but never bubble up — a tool that produced a |
| 197 | /// result shouldn't be marked failed because the spillover writer |
| 198 | /// couldn't reach disk; we degrade to no-op and the model gets the |
| 199 | /// original (large) content. |
| 200 | /// |
| 201 | /// Error results (`success == false`) are skipped: error messages |
| 202 | /// are typically short, and turning them into a "see file" pointer |
| 203 | /// would just hide the error from the model's reasoning. |
| 204 | pub fn apply_spillover(result: &mut ToolResult, tool_id: &str) -> Option<PathBuf> { |
| 205 | if !result.success { |
| 206 | return None; |
| 207 | } |
| 208 | if result.content.len() <= SPILLOVER_THRESHOLD_BYTES { |
| 209 | return None; |
| 210 | } |
| 211 | let total = result.content.len(); |
| 212 | let outcome = match maybe_spillover( |
| 213 | tool_id, |
| 214 | &result.content, |
| 215 | SPILLOVER_THRESHOLD_BYTES, |
| 216 | SPILLOVER_HEAD_BYTES, |
| 217 | ) { |
| 218 | Ok(Some(pair)) => pair, |
| 219 | Ok(None) => return None, |
| 220 | Err(err) => { |
| 221 | tracing::warn!( |
| 222 | target: "spillover", |
| 223 | ?err, |
| 224 | tool_id, |
| 225 | "spillover write failed; passing original content through" |
| 226 | ); |
| 227 | return None; |
| 228 | } |
| 229 | }; |
| 230 | let (head, path) = outcome; |
| 231 | let path_str = path.display().to_string(); |
| 232 | let footer = format!( |
| 233 | "\n\n[Output truncated: {head_kib} KiB of {total_kib} KiB shown. \ |
| 234 | Full output saved to {path_str}. Use `read_file path={path_str}` \ |
| 235 | if you need the elided tail.]", |
| 236 | head_kib = head.len() / 1024, |
| 237 | total_kib = total / 1024, |
| 238 | ); |
| 239 | result.content = format!("{head}{footer}"); |
| 240 | let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({})); |
| 241 | if let Some(obj) = metadata.as_object_mut() { |
| 242 | obj.insert("spillover_path".into(), serde_json::Value::String(path_str)); |
| 243 | } else { |
| 244 | // Pre-existing metadata that wasn't a JSON object (rare, |
| 245 | // possibly an array). Replace with an object so we can |
| 246 | // attach our key without losing prior data — wrap it under |
| 247 | // a `_prior` field so callers that introspect can recover. |
| 248 | let prior = std::mem::replace(metadata, serde_json::json!({})); |
| 249 | if let Some(obj) = metadata.as_object_mut() { |
| 250 | obj.insert("_prior".into(), prior); |
| 251 | obj.insert( |
| 252 | "spillover_path".into(), |
| 253 | serde_json::Value::String(path.display().to_string()), |
| 254 | ); |
| 255 | } |
| 256 | } |
| 257 | Some(path) |
| 258 | } |
| 259 | |
| 260 | /// Sanitise a tool call id for use as a filename. Keeps ASCII |
| 261 | /// alphanumerics, `-`, and `_`; rejects `.` to keep `..` traversal |
| 262 | /// out, rejects empty results. Returns `None` if the input contains |
| 263 | /// no acceptable characters. |
| 264 | fn sanitise_id(id: &str) -> Option<String> { |
| 265 | let cleaned: String = id |
| 266 | .chars() |
| 267 | .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') |
| 268 | .collect(); |
| 269 | if cleaned.is_empty() { |
| 270 | None |
| 271 | } else { |
| 272 | Some(cleaned) |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | /// Override the spillover root for tests so they don't pollute the |
| 277 | /// user's real `~/.deepseek/` directory. Wraps the body with a |
| 278 | /// temporary `HOME` override that gets restored on drop. |
| 279 | #[cfg(test)] |
| 280 | fn with_test_home<F, R>(home: &Path, f: F) -> R |
| 281 | where |
| 282 | F: FnOnce() -> R, |
| 283 | { |
| 284 | // SAFETY: tests in this module serialize through `TEST_GUARD` |
| 285 | // because they share process-wide `$HOME`. Without the guard, |
| 286 | // parallel tests could observe each other's overrides. |
| 287 | let prior = std::env::var_os("HOME"); |
| 288 | // SAFETY: caller holds the test guard. |
| 289 | unsafe { |
| 290 | std::env::set_var("HOME", home); |
| 291 | } |
| 292 | let out = f(); |
| 293 | // SAFETY: caller holds the test guard. |
| 294 | unsafe { |
| 295 | if let Some(p) = prior { |
| 296 | std::env::set_var("HOME", p); |
| 297 | } else { |
| 298 | std::env::remove_var("HOME"); |
| 299 | } |
| 300 | } |
| 301 | out |
| 302 | } |
| 303 | |
| 304 | #[cfg(test)] |
| 305 | mod tests { |
| 306 | use super::*; |
| 307 | use std::sync::Mutex; |
| 308 | use tempfile::tempdir; |
| 309 | |
| 310 | /// Tests in this module serialize through this guard because |
| 311 | /// they mutate process-global `$HOME`. Without it, cargo's |
| 312 | /// parallel runner would observe interleaved overrides. |
| 313 | static TEST_GUARD: Mutex<()> = Mutex::new(()); |
| 314 | |
| 315 | fn setup() -> std::sync::MutexGuard<'static, ()> { |
| 316 | TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner()) |
| 317 | } |
| 318 | |
| 319 | #[test] |
| 320 | fn sanitise_id_keeps_safe_chars_and_drops_dangerous() { |
| 321 | assert_eq!(super::sanitise_id("abc-123_x"), Some("abc-123_x".into())); |
| 322 | // `.` is dropped to keep `..` out of the path. |
| 323 | assert_eq!(super::sanitise_id("../etc"), Some("etc".into())); |
| 324 | assert_eq!(super::sanitise_id("/etc/passwd"), Some("etcpasswd".into())); |
| 325 | // Empty-after-sanitise → None. |
| 326 | assert!(super::sanitise_id("...").is_none()); |
| 327 | assert!(super::sanitise_id("").is_none()); |
| 328 | } |
| 329 | |
| 330 | #[test] |
| 331 | fn write_spillover_creates_directory_and_writes_file() { |
| 332 | let _g = setup(); |
| 333 | let tmp = tempdir().unwrap(); |
| 334 | with_test_home(tmp.path(), || { |
| 335 | let path = write_spillover("call-abc", "hello world").expect("write"); |
| 336 | assert!(path.exists(), "{path:?} missing"); |
| 337 | let body = fs::read_to_string(&path).unwrap(); |
| 338 | assert_eq!(body, "hello world"); |
| 339 | // Directory landed under `<HOME>/.deepseek/tool_outputs/`. |
| 340 | // Compare components instead of a substring on `to_string_lossy` |
| 341 | // — Windows uses `\` as the separator so a `/` substring match |
| 342 | // would falsely fail there. |
| 343 | let components: Vec<&str> = path |
| 344 | .components() |
| 345 | .filter_map(|c| c.as_os_str().to_str()) |
| 346 | .collect(); |
| 347 | assert!( |
| 348 | components.contains(&".deepseek") && components.contains(&"tool_outputs"), |
| 349 | "spillover path missing expected `.deepseek/tool_outputs/...` segments: {path:?}" |
| 350 | ); |
| 351 | }); |
| 352 | } |
| 353 | |
| 354 | #[test] |
| 355 | fn write_spillover_rejects_empty_id() { |
| 356 | let _g = setup(); |
| 357 | let tmp = tempdir().unwrap(); |
| 358 | with_test_home(tmp.path(), || { |
| 359 | let err = write_spillover("...", "x").unwrap_err(); |
| 360 | assert_eq!(err.kind(), io::ErrorKind::InvalidInput); |
| 361 | }); |
| 362 | } |
| 363 | |
| 364 | #[test] |
| 365 | fn maybe_spillover_returns_none_below_threshold() { |
| 366 | let _g = setup(); |
| 367 | let tmp = tempdir().unwrap(); |
| 368 | with_test_home(tmp.path(), || { |
| 369 | let out = maybe_spillover("call-1", "tiny content", 100 * 1024, 4 * 1024).expect("ok"); |
| 370 | assert!(out.is_none()); |
| 371 | }); |
| 372 | } |
| 373 | |
| 374 | #[test] |
| 375 | fn maybe_spillover_writes_and_returns_head_above_threshold() { |
| 376 | let _g = setup(); |
| 377 | let tmp = tempdir().unwrap(); |
| 378 | with_test_home(tmp.path(), || { |
| 379 | // Content larger than the threshold. |
| 380 | let big = "A".repeat(2_000); |
| 381 | let (head, path) = maybe_spillover("call-2", &big, 1_000, 256) |
| 382 | .expect("ok") |
| 383 | .expect("should have spilled"); |
| 384 | // Head is bounded. |
| 385 | assert_eq!(head.len(), 256); |
| 386 | // Full content on disk. |
| 387 | let body = fs::read_to_string(&path).unwrap(); |
| 388 | assert_eq!(body.len(), 2_000); |
| 389 | }); |
| 390 | } |
| 391 | |
| 392 | #[test] |
| 393 | fn maybe_spillover_does_not_split_inside_a_codepoint() { |
| 394 | let _g = setup(); |
| 395 | let tmp = tempdir().unwrap(); |
| 396 | with_test_home(tmp.path(), || { |
| 397 | // 4 byte chars; ask for 3 bytes of head → walks back to |
| 398 | // the previous char boundary (0). |
| 399 | let s = "🐳🐳🐳🐳"; // 4 × 4-byte codepoints |
| 400 | assert_eq!(s.len(), 16); |
| 401 | let (head, _) = maybe_spillover("call-3", s, 1, 3) |
| 402 | .expect("ok") |
| 403 | .expect("spilled"); |
| 404 | // 3 isn't a char boundary in this string; walk back → 0. |
| 405 | assert_eq!(head, ""); |
| 406 | // Asking for 4 bytes lands on the first char boundary. |
| 407 | let (head, _) = maybe_spillover("call-3b", s, 1, 4) |
| 408 | .expect("ok") |
| 409 | .expect("spilled"); |
| 410 | assert_eq!(head, "🐳"); |
| 411 | }); |
| 412 | } |
| 413 | |
| 414 | #[test] |
| 415 | fn prune_older_than_handles_missing_root() { |
| 416 | let _g = setup(); |
| 417 | let tmp = tempdir().unwrap(); |
| 418 | with_test_home(tmp.path(), || { |
| 419 | // Nothing has ever written; root doesn't exist; that's fine. |
| 420 | let count = prune_older_than(SPILLOVER_MAX_AGE).expect("ok"); |
| 421 | assert_eq!(count, 0); |
| 422 | }); |
| 423 | } |
| 424 | |
| 425 | // The mtime backdate uses utimensat (Unix-only). On Windows the |
| 426 | // filetime_set_modified helper is a no-op, so the prune wouldn't see |
| 427 | // any stale files. Gate the whole test on `cfg(unix)` instead of |
| 428 | // testing a no-op path that can't fail meaningfully. |
| 429 | #[test] |
| 430 | #[cfg(unix)] |
| 431 | fn prune_older_than_keeps_fresh_files_drops_stale_ones() { |
| 432 | let _g = setup(); |
| 433 | let tmp = tempdir().unwrap(); |
| 434 | with_test_home(tmp.path(), || { |
| 435 | let fresh = write_spillover("fresh", "x").unwrap(); |
| 436 | let stale = write_spillover("stale", "y").unwrap(); |
| 437 | |
| 438 | // Backdate `stale` to 30 days ago. |
| 439 | let thirty_days = SystemTime::now() - Duration::from_secs(30 * 24 * 60 * 60); |
| 440 | filetime_set_modified(&stale, thirty_days); |
| 441 | |
| 442 | let pruned = prune_older_than(SPILLOVER_MAX_AGE).unwrap(); |
| 443 | assert_eq!(pruned, 1); |
| 444 | assert!(fresh.exists()); |
| 445 | assert!(!stale.exists()); |
| 446 | }); |
| 447 | } |
| 448 | |
| 449 | /// Set the mtime on a file. The workspace doesn't pull the |
| 450 | /// `filetime` crate, so we reach for `utimensat` directly on |
| 451 | /// Unix. Windows is a no-op — the prune semantics are the same |
| 452 | /// and the per-cycle stress test lives on the Unix path. |
| 453 | #[cfg(unix)] |
| 454 | fn filetime_set_modified(path: &Path, when: SystemTime) { |
| 455 | let secs = when |
| 456 | .duration_since(SystemTime::UNIX_EPOCH) |
| 457 | .unwrap_or_default() |
| 458 | .as_secs() as libc::time_t; |
| 459 | let times = [ |
| 460 | libc::timespec { |
| 461 | tv_sec: secs, |
| 462 | tv_nsec: 0, |
| 463 | }, |
| 464 | libc::timespec { |
| 465 | tv_sec: secs, |
| 466 | tv_nsec: 0, |
| 467 | }, |
| 468 | ]; |
| 469 | let path_c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap(); |
| 470 | // SAFETY: path_c is a valid CString; times is a 2-element array |
| 471 | // matching utimensat's signature. |
| 472 | let rc = unsafe { libc::utimensat(libc::AT_FDCWD, path_c.as_ptr(), times.as_ptr(), 0) }; |
| 473 | assert_eq!( |
| 474 | rc, |
| 475 | 0, |
| 476 | "utimensat failed: {}", |
| 477 | std::io::Error::last_os_error() |
| 478 | ); |
| 479 | } |
| 480 | |
| 481 | // Windows stub removed in v0.8.8 — the only caller of |
| 482 | // `filetime_set_modified` is `prune_older_than_keeps_fresh_files_drops_stale_ones`, |
| 483 | // which is now `#[cfg(unix)]` because mtime backdating requires |
| 484 | // `utimensat` and a Windows no-op stub can't make the assertion pass |
| 485 | // anyway. Keeping the stub triggered `-D dead-code` on Windows builds |
| 486 | // (the prune test was the only caller) and broke `Test (windows-latest)`. |
| 487 | |
| 488 | #[test] |
| 489 | fn apply_spillover_is_noop_below_threshold() { |
| 490 | let _g = setup(); |
| 491 | let tmp = tempdir().unwrap(); |
| 492 | with_test_home(tmp.path(), || { |
| 493 | let mut result = ToolResult::success("small payload"); |
| 494 | let path = apply_spillover(&mut result, "call-small"); |
| 495 | assert!(path.is_none()); |
| 496 | assert_eq!(result.content, "small payload"); |
| 497 | assert!(result.metadata.is_none()); |
| 498 | }); |
| 499 | } |
| 500 | |
| 501 | #[test] |
| 502 | fn apply_spillover_is_noop_for_error_results() { |
| 503 | let _g = setup(); |
| 504 | let tmp = tempdir().unwrap(); |
| 505 | with_test_home(tmp.path(), || { |
| 506 | // Even very large error messages are passed through — |
| 507 | // truncating an error would hide it from the model. |
| 508 | let big_err = "boom\n".repeat(50_000); |
| 509 | let mut result = ToolResult::error(big_err.clone()); |
| 510 | let path = apply_spillover(&mut result, "call-err"); |
| 511 | assert!(path.is_none()); |
| 512 | assert_eq!(result.content, big_err); |
| 513 | }); |
| 514 | } |
| 515 | |
| 516 | #[test] |
| 517 | fn apply_spillover_truncates_and_stamps_metadata_above_threshold() { |
| 518 | let _g = setup(); |
| 519 | let tmp = tempdir().unwrap(); |
| 520 | with_test_home(tmp.path(), || { |
| 521 | // 200 KiB body — well above the 100 KiB threshold. |
| 522 | let big = "X".repeat(200 * 1024); |
| 523 | let mut result = ToolResult::success(big.clone()); |
| 524 | let path = apply_spillover(&mut result, "call-big").expect("should spill"); |
| 525 | |
| 526 | // Inline content shrunk to head + footer. |
| 527 | assert!(result.content.len() < big.len()); |
| 528 | assert!( |
| 529 | result.content.contains("Output truncated:"), |
| 530 | "footer missing: {}", |
| 531 | &result.content[result.content.len().saturating_sub(200)..] |
| 532 | ); |
| 533 | assert!(result.content.contains("read_file path=")); |
| 534 | |
| 535 | // Full bytes are on disk at the returned path. |
| 536 | assert!(path.exists(), "spillover file missing: {path:?}"); |
| 537 | let body = fs::read_to_string(&path).unwrap(); |
| 538 | assert_eq!(body.len(), 200 * 1024); |
| 539 | |
| 540 | // metadata.spillover_path stamped for the UI to find. |
| 541 | let metadata = result.metadata.expect("metadata stamped"); |
| 542 | let stamped = metadata |
| 543 | .get("spillover_path") |
| 544 | .and_then(serde_json::Value::as_str) |
| 545 | .expect("spillover_path key present"); |
| 546 | assert_eq!(stamped, path.display().to_string()); |
| 547 | }); |
| 548 | } |
| 549 | |
| 550 | #[test] |
| 551 | fn apply_spillover_preserves_existing_metadata() { |
| 552 | let _g = setup(); |
| 553 | let tmp = tempdir().unwrap(); |
| 554 | with_test_home(tmp.path(), || { |
| 555 | let big = "Y".repeat(200 * 1024); |
| 556 | let mut result = ToolResult::success(big) |
| 557 | .with_metadata(serde_json::json!({"prior_key": "prior_value"})); |
| 558 | let path = apply_spillover(&mut result, "call-meta").expect("should spill"); |
| 559 | |
| 560 | let metadata = result.metadata.expect("metadata present"); |
| 561 | // Prior keys survive. |
| 562 | assert_eq!( |
| 563 | metadata |
| 564 | .get("prior_key") |
| 565 | .and_then(serde_json::Value::as_str), |
| 566 | Some("prior_value") |
| 567 | ); |
| 568 | // New key added alongside. |
| 569 | assert_eq!( |
| 570 | metadata |
| 571 | .get("spillover_path") |
| 572 | .and_then(serde_json::Value::as_str), |
| 573 | Some(path.display().to_string().as_str()) |
| 574 | ); |
| 575 | }); |
| 576 | } |
| 577 | |
| 578 | #[test] |
| 579 | fn apply_spillover_wraps_non_object_metadata_under_prior_key() { |
| 580 | // Defends against a tool whose `metadata` is something |
| 581 | // other than a JSON object (rare — most use the `json!({})` |
| 582 | // pattern — but legal per `serde_json::Value`). The |
| 583 | // spillover writer must add `spillover_path` without losing |
| 584 | // the prior payload. |
| 585 | let _g = setup(); |
| 586 | let tmp = tempdir().unwrap(); |
| 587 | with_test_home(tmp.path(), || { |
| 588 | let big = "Z".repeat(200 * 1024); |
| 589 | let mut result = ToolResult::success(big).with_metadata(serde_json::json!([ |
| 590 | "unexpected", |
| 591 | "array", |
| 592 | "payload" |
| 593 | ])); |
| 594 | let path = apply_spillover(&mut result, "call-arr").expect("should spill"); |
| 595 | |
| 596 | let metadata = result.metadata.expect("metadata stamped"); |
| 597 | // Prior payload re-homed under `_prior`. |
| 598 | let prior = metadata.get("_prior").expect("_prior wrap key present"); |
| 599 | assert_eq!( |
| 600 | prior, |
| 601 | &serde_json::json!(["unexpected", "array", "payload"]), |
| 602 | "prior array should round-trip under _prior" |
| 603 | ); |
| 604 | // New key alongside. |
| 605 | assert_eq!( |
| 606 | metadata |
| 607 | .get("spillover_path") |
| 608 | .and_then(serde_json::Value::as_str), |
| 609 | Some(path.display().to_string().as_str()) |
| 610 | ); |
| 611 | }); |
| 612 | } |
| 613 | } |
| 614 |