use std::fs;
use std::io;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use crate::tools::spec::ToolResult;
#[cfg(test)]
use std::path::Path;
pub const SPILLOVER_DIR_NAME: &str = "tool_outputs";
pub const SPILLOVER_THRESHOLD_BYTES: usize = 100 * 1024;
pub const SPILLOVER_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
#[cfg(test)]
static TEST_SPILLOVER_ROOT: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
#[cfg(test)]
pub(crate) static TEST_SPILLOVER_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[must_use]
pub fn spillover_root() -> Option<PathBuf> {
#[cfg(test)]
if let Some(root) = TEST_SPILLOVER_ROOT
.lock()
.unwrap_or_else(|err| err.into_inner())
.clone()
{
return Some(root);
}
Some(dirs::home_dir()?.join(".deepseek").join(SPILLOVER_DIR_NAME))
}
#[cfg(test)]
pub(crate) fn set_test_spillover_root(root: Option<PathBuf>) -> Option<PathBuf> {
let mut guard = TEST_SPILLOVER_ROOT
.lock()
.unwrap_or_else(|err| err.into_inner());
std::mem::replace(&mut *guard, root)
}
#[must_use]
pub fn spillover_path(id: &str) -> Option<PathBuf> {
let sanitised = sanitise_id(id)?;
Some(spillover_root()?.join(format!("{sanitised}.txt")))
}
#[must_use]
pub fn sha_spillover_path(sha: &str) -> Option<PathBuf> {
let sha = sha.trim().to_ascii_lowercase();
if !is_valid_sha256(&sha) {
return None;
}
Some(spillover_root()?.join(format!("sha_{sha}.txt")))
}
#[must_use]
pub fn is_valid_sha256(s: &str) -> bool {
s.len() == 64
&& s.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
}
pub fn write_sha_spillover(sha: &str, content: &str) -> io::Result<PathBuf> {
let path = sha_spillover_path(sha).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"sha must be a 64-char lowercase hex digest",
)
})?;
if path.exists() {
return Ok(path);
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
crate::utils::write_atomic(&path, content.as_bytes())?;
Ok(path)
}
pub fn write_spillover(id: &str, content: &str) -> io::Result<PathBuf> {
let path = spillover_path(id).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"could not resolve spillover path (empty/invalid id or missing home directory)",
)
})?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
crate::utils::write_atomic(&path, content.as_bytes())?;
Ok(path)
}
pub fn prune_older_than(max_age: Duration) -> io::Result<usize> {
let Some(root) = spillover_root() else {
return Ok(0);
};
if !root.exists() {
return Ok(0);
}
let cutoff = SystemTime::now()
.checked_sub(max_age)
.unwrap_or(SystemTime::UNIX_EPOCH);
let mut pruned = 0usize;
for entry in fs::read_dir(&root)? {
let entry = match entry {
Ok(e) => e,
Err(err) => {
tracing::warn!(target: "spillover", ?err, "skipping unreadable dir entry");
continue;
}
};
let path = entry.path();
if !path.is_file() {
continue;
}
let modified = match entry.metadata().and_then(|m| m.modified()) {
Ok(t) => t,
Err(err) => {
tracing::warn!(target: "spillover", ?err, ?path, "skipping unreadable mtime");
continue;
}
};
if modified < cutoff {
if let Err(err) = fs::remove_file(&path) {
tracing::warn!(target: "spillover", ?err, ?path, "spillover prune skipped a file");
continue;
}
pruned += 1;
}
}
Ok(pruned)
}
pub fn maybe_spillover(
id: &str,
content: &str,
threshold: usize,
head_bytes: usize,
) -> io::Result<Option<(String, PathBuf)>> {
if content.len() <= threshold {
return Ok(None);
}
let path = write_spillover(id, content)?;
let cut = head_bytes.min(content.len());
let cut = (0..=cut)
.rev()
.find(|&i| content.is_char_boundary(i))
.unwrap_or(0);
Ok(Some((content[..cut].to_string(), path)))
}
pub const SPILLOVER_HEAD_BYTES: usize = 32 * 1024;
#[allow(dead_code)]
pub fn apply_spillover(result: &mut ToolResult, tool_id: &str) -> Option<PathBuf> {
apply_spillover_inner(result, tool_id, None)
}
pub fn apply_spillover_with_artifact(
result: &mut ToolResult,
tool_id: &str,
tool_name: &str,
session_id: &str,
) -> Option<PathBuf> {
apply_spillover_inner(
result,
tool_id,
Some(ArtifactSpilloverContext {
tool_name,
session_id,
}),
)
}
struct ArtifactSpilloverContext<'a> {
tool_name: &'a str,
session_id: &'a str,
}
fn apply_spillover_inner(
result: &mut ToolResult,
tool_id: &str,
artifact_context: Option<ArtifactSpilloverContext<'_>>,
) -> Option<PathBuf> {
if !result.success {
return None;
}
if result.content.len() <= SPILLOVER_THRESHOLD_BYTES {
return None;
}
let original_content = result.content.clone();
let total = original_content.len();
let outcome = match maybe_spillover(
tool_id,
&original_content,
SPILLOVER_THRESHOLD_BYTES,
SPILLOVER_HEAD_BYTES,
) {
Ok(Some(pair)) => pair,
Ok(None) => return None,
Err(err) => {
tracing::warn!(
target: "spillover",
?err,
tool_id,
"spillover write failed; passing original content through"
);
return None;
}
};
let (head, path) = outcome;
let path_str = path.display().to_string();
let mut artifact_path = None;
if let Some(context) = artifact_context {
let artifact_id = crate::artifacts::artifact_id_for_tool_call(tool_id);
match crate::artifacts::write_session_artifact(
context.session_id,
&artifact_id,
&original_content,
) {
Ok((absolute_path, relative_path)) => {
let record = crate::artifacts::record_tool_output_artifact(
context.session_id,
tool_id,
context.tool_name,
relative_path.clone(),
&original_content,
);
let transcript_ref = crate::artifacts::TranscriptArtifactRef::from(&record);
result.content = crate::artifacts::render_transcript_artifact_ref(&transcript_ref);
artifact_path = Some((absolute_path, relative_path, record));
}
Err(err) => {
tracing::warn!(
target: "spillover",
?err,
tool_id,
"session artifact write failed; falling back to legacy spillover footer"
);
}
}
}
if artifact_path.is_none() {
let footer = format!(
"\n\n[Output truncated: {head_kib} KiB of {total_kib} KiB shown. \
Full output saved to {path_str}. Use \
`retrieve_tool_result ref={tool_id} mode=tail` or \
`retrieve_tool_result ref={tool_id} mode=query query=<text>` \
if you need the elided output.]",
head_kib = head.len() / 1024,
total_kib = total / 1024,
);
result.content = format!("{head}{footer}");
}
let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({}));
if let Some(obj) = metadata.as_object_mut() {
if let Some((absolute_path, relative_path, record)) = artifact_path.as_ref() {
obj.insert(
"spillover_path".into(),
serde_json::Value::String(absolute_path.display().to_string()),
);
obj.insert(
"legacy_spillover_path".into(),
serde_json::Value::String(path_str),
);
obj.insert(
"artifact_id".into(),
serde_json::Value::String(record.id.clone()),
);
obj.insert(
"artifact_session_id".into(),
serde_json::Value::String(record.session_id.clone()),
);
obj.insert(
"artifact_relative_path".into(),
serde_json::Value::String(crate::artifacts::format_artifact_relative_path(
relative_path,
)),
);
obj.insert(
"artifact_path".into(),
serde_json::Value::String(absolute_path.display().to_string()),
);
obj.insert(
"artifact_byte_size".into(),
serde_json::Value::Number(serde_json::Number::from(record.byte_size)),
);
obj.insert(
"artifact_preview".into(),
serde_json::Value::String(record.preview.clone()),
);
} else {
obj.insert("spillover_path".into(), serde_json::Value::String(path_str));
}
} else {
let prior = std::mem::replace(metadata, serde_json::json!({}));
if let Some(obj) = metadata.as_object_mut() {
obj.insert("_prior".into(), prior);
if let Some((absolute_path, relative_path, record)) = artifact_path.as_ref() {
obj.insert(
"spillover_path".into(),
serde_json::Value::String(absolute_path.display().to_string()),
);
obj.insert(
"legacy_spillover_path".into(),
serde_json::Value::String(path.display().to_string()),
);
obj.insert(
"artifact_id".into(),
serde_json::Value::String(record.id.clone()),
);
obj.insert(
"artifact_session_id".into(),
serde_json::Value::String(record.session_id.clone()),
);
obj.insert(
"artifact_relative_path".into(),
serde_json::Value::String(crate::artifacts::format_artifact_relative_path(
relative_path,
)),
);
obj.insert(
"artifact_path".into(),
serde_json::Value::String(absolute_path.display().to_string()),
);
obj.insert(
"artifact_byte_size".into(),
serde_json::Value::Number(serde_json::Number::from(record.byte_size)),
);
obj.insert(
"artifact_preview".into(),
serde_json::Value::String(record.preview.clone()),
);
} else {
obj.insert(
"spillover_path".into(),
serde_json::Value::String(path.display().to_string()),
);
}
}
}
artifact_path
.map(|(absolute_path, _, _)| absolute_path)
.or(Some(path))
}
fn sanitise_id(id: &str) -> Option<String> {
let cleaned: String = id
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.collect();
if cleaned.is_empty() {
None
} else {
Some(cleaned)
}
}
#[cfg(test)]
fn with_test_home<F, R>(home: &Path, f: F) -> R
where
F: FnOnce() -> R,
{
let _artifact_guard = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
.lock()
.unwrap_or_else(|err| err.into_inner());
struct StorageRootOverride {
prior_spillover: Option<PathBuf>,
prior_artifacts: Option<PathBuf>,
}
impl Drop for StorageRootOverride {
fn drop(&mut self) {
set_test_spillover_root(self.prior_spillover.take());
crate::artifacts::set_test_artifact_sessions_root(self.prior_artifacts.take());
}
}
let prior_spillover =
set_test_spillover_root(Some(home.join(".deepseek").join(SPILLOVER_DIR_NAME)));
let prior_artifacts = crate::artifacts::set_test_artifact_sessions_root(Some(
home.join(".deepseek").join("sessions"),
));
let _restore = StorageRootOverride {
prior_spillover,
prior_artifacts,
};
f()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn setup() -> std::sync::MutexGuard<'static, ()> {
super::TEST_SPILLOVER_GUARD
.lock()
.unwrap_or_else(|e| e.into_inner())
}
#[test]
fn with_test_home_overrides_storage_roots_without_home_resolution() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
assert_eq!(
spillover_root().as_deref(),
Some(tmp.path().join(".deepseek").join("tool_outputs").as_path())
);
assert_eq!(
crate::artifacts::session_artifact_absolute_path(
"session-123",
&PathBuf::from("artifacts").join("art_call-big.txt")
)
.as_deref(),
Some(
tmp.path()
.join(".deepseek")
.join("sessions")
.join("session-123")
.join("artifacts")
.join("art_call-big.txt")
.as_path()
)
);
});
}
#[test]
fn sanitise_id_keeps_safe_chars_and_drops_dangerous() {
assert_eq!(super::sanitise_id("abc-123_x"), Some("abc-123_x".into()));
assert_eq!(super::sanitise_id("../etc"), Some("etc".into()));
assert_eq!(super::sanitise_id("/etc/passwd"), Some("etcpasswd".into()));
assert!(super::sanitise_id("...").is_none());
assert!(super::sanitise_id("").is_none());
}
#[test]
fn write_spillover_creates_directory_and_writes_file() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
let path = write_spillover("call-abc", "hello world").expect("write");
assert!(path.exists(), "{path:?} missing");
let body = fs::read_to_string(&path).unwrap();
assert_eq!(body, "hello world");
let components: Vec<&str> = path
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
assert!(
components.contains(&".deepseek") && components.contains(&"tool_outputs"),
"spillover path missing expected `.deepseek/tool_outputs/...` segments: {path:?}"
);
});
}
#[test]
fn write_spillover_rejects_empty_id() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
let err = write_spillover("...", "x").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
});
}
#[test]
fn maybe_spillover_returns_none_below_threshold() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
let out = maybe_spillover("call-1", "tiny content", 100 * 1024, 4 * 1024).expect("ok");
assert!(out.is_none());
});
}
#[test]
fn maybe_spillover_writes_and_returns_head_above_threshold() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
let big = "A".repeat(2_000);
let (head, path) = maybe_spillover("call-2", &big, 1_000, 256)
.expect("ok")
.expect("should have spilled");
assert_eq!(head.len(), 256);
let body = fs::read_to_string(&path).unwrap();
assert_eq!(body.len(), 2_000);
});
}
#[test]
fn maybe_spillover_does_not_split_inside_a_codepoint() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
let s = "🐳🐳🐳🐳"; assert_eq!(s.len(), 16);
let (head, _) = maybe_spillover("call-3", s, 1, 3)
.expect("ok")
.expect("spilled");
assert_eq!(head, "");
let (head, _) = maybe_spillover("call-3b", s, 1, 4)
.expect("ok")
.expect("spilled");
assert_eq!(head, "🐳");
});
}
#[test]
fn prune_older_than_handles_missing_root() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
let count = prune_older_than(SPILLOVER_MAX_AGE).expect("ok");
assert_eq!(count, 0);
});
}
#[test]
#[cfg(unix)]
fn prune_older_than_keeps_fresh_files_drops_stale_ones() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
let fresh = write_spillover("fresh", "x").unwrap();
let stale = write_spillover("stale", "y").unwrap();
let thirty_days = SystemTime::now() - Duration::from_secs(30 * 24 * 60 * 60);
filetime_set_modified(&stale, thirty_days);
let pruned = prune_older_than(SPILLOVER_MAX_AGE).unwrap();
assert_eq!(pruned, 1);
assert!(fresh.exists());
assert!(!stale.exists());
});
}
#[cfg(unix)]
fn filetime_set_modified(path: &Path, when: SystemTime) {
let secs = when
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as libc::time_t;
let times = [
libc::timespec {
tv_sec: secs,
tv_nsec: 0,
},
libc::timespec {
tv_sec: secs,
tv_nsec: 0,
},
];
let path_c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap();
let rc = unsafe { libc::utimensat(libc::AT_FDCWD, path_c.as_ptr(), times.as_ptr(), 0) };
assert_eq!(
rc,
0,
"utimensat failed: {}",
std::io::Error::last_os_error()
);
}
#[test]
fn apply_spillover_is_noop_below_threshold() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
let mut result = ToolResult::success("small payload");
let path = apply_spillover(&mut result, "call-small");
assert!(path.is_none());
assert_eq!(result.content, "small payload");
assert!(result.metadata.is_none());
});
}
#[test]
fn apply_spillover_is_noop_for_error_results() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
let big_err = "boom\n".repeat(50_000);
let mut result = ToolResult::error(big_err.clone());
let path = apply_spillover(&mut result, "call-err");
assert!(path.is_none());
assert_eq!(result.content, big_err);
});
}
#[test]
fn apply_spillover_truncates_and_stamps_metadata_above_threshold() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
let big = "X".repeat(200 * 1024);
let mut result = ToolResult::success(big.clone());
let path = apply_spillover(&mut result, "call-big").expect("should spill");
assert!(result.content.len() < big.len());
assert!(
result.content.contains("Output truncated:"),
"footer missing: {}",
&result.content[result.content.len().saturating_sub(200)..]
);
assert!(result.content.contains("retrieve_tool_result ref=call-big"));
assert!(path.exists(), "spillover file missing: {path:?}");
let body = fs::read_to_string(&path).unwrap();
assert_eq!(body.len(), 200 * 1024);
let metadata = result.metadata.expect("metadata stamped");
let stamped = metadata
.get("spillover_path")
.and_then(serde_json::Value::as_str)
.expect("spillover_path key present");
assert_eq!(stamped, path.display().to_string());
});
}
#[test]
fn apply_spillover_with_artifact_writes_session_file_and_ref_block() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
let big = "checking crate ... error[E0425]: cannot find value\n".repeat(4_000);
let mut result = ToolResult::success(big.clone());
let path =
apply_spillover_with_artifact(&mut result, "call-big", "exec_shell", "session-123")
.expect("should spill");
let session_artifact = tmp
.path()
.join(".deepseek")
.join("sessions")
.join("session-123")
.join("artifacts")
.join("art_call-big.txt");
assert_eq!(path, session_artifact);
assert_eq!(fs::read_to_string(&session_artifact).unwrap(), big);
assert!(
tmp.path()
.join(".deepseek/tool_outputs/call-big.txt")
.exists(),
"legacy spillover file should remain during transition"
);
assert!(result.content.starts_with("[artifact: exec_shell]"));
assert!(result.content.contains("id: art_call-big"));
assert!(result.content.contains("tool_call_id: call-big"));
assert!(
result
.content
.contains("path: artifacts/art_call-big.txt")
);
assert!(!result.content.contains("Output truncated:"));
let metadata = result.metadata.expect("metadata stamped");
assert_eq!(
metadata
.get("artifact_id")
.and_then(serde_json::Value::as_str),
Some("art_call-big")
);
assert_eq!(
metadata
.get("artifact_relative_path")
.and_then(serde_json::Value::as_str),
Some("artifacts/art_call-big.txt")
);
assert_eq!(
metadata
.get("artifact_session_id")
.and_then(serde_json::Value::as_str),
Some("session-123")
);
});
}
#[test]
fn apply_spillover_preserves_existing_metadata() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
let big = "Y".repeat(200 * 1024);
let mut result = ToolResult::success(big)
.with_metadata(serde_json::json!({"prior_key": "prior_value"}));
let path = apply_spillover(&mut result, "call-meta").expect("should spill");
let metadata = result.metadata.expect("metadata present");
assert_eq!(
metadata
.get("prior_key")
.and_then(serde_json::Value::as_str),
Some("prior_value")
);
assert_eq!(
metadata
.get("spillover_path")
.and_then(serde_json::Value::as_str),
Some(path.display().to_string().as_str())
);
});
}
#[test]
fn apply_spillover_wraps_non_object_metadata_under_prior_key() {
let _g = setup();
let tmp = tempdir().unwrap();
with_test_home(tmp.path(), || {
let big = "Z".repeat(200 * 1024);
let mut result = ToolResult::success(big).with_metadata(serde_json::json!([
"unexpected",
"array",
"payload"
]));
let path = apply_spillover(&mut result, "call-arr").expect("should spill");
let metadata = result.metadata.expect("metadata stamped");
let prior = metadata.get("_prior").expect("_prior wrap key present");
assert_eq!(
prior,
&serde_json::json!(["unexpected", "array", "payload"]),
"prior array should round-trip under _prior"
);
assert_eq!(
metadata
.get("spillover_path")
.and_then(serde_json::Value::as_str),
Some(path.display().to_string().as_str())
);
});
}
}