返回 DeepSeek-TUI-2026
truncate.rs
根目录 / crates / tui / src / tools / truncate.rs
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
614 lines RUST