use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use serde_json::Value;
pub const SPILL_FILE_CAP_BYTES: usize = 16 * 1024 * 1024;
pub const SPILL_SESSION_CAP_BYTES: u64 = 256 * 1024 * 1024;
const SPILL_BUILTINS: &[&str] = &[
"bash",
"powershell",
"shell_start",
"shell_send",
"shell_end",
"grep",
"find",
"ls",
"subagent",
"subagent_collect",
"subagent_resume",
"subagent_status",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ToolSpillPolicy {
pub threshold_bytes: usize,
pub head_bytes: usize,
pub tail_bytes: usize,
}
impl ToolSpillPolicy {
pub const OFF: Self = Self {
threshold_bytes: 0,
head_bytes: agent_core::config::DEFAULT_TOOL_OUTPUT_SPILL_HEAD_BYTES,
tail_bytes: agent_core::config::DEFAULT_TOOL_OUTPUT_SPILL_TAIL_BYTES,
};
pub fn from_config(config: &agent_core::config::SynapsConfig) -> Self {
Self {
threshold_bytes: config.tool_output_spill_bytes,
head_bytes: config.tool_output_spill_head_bytes,
tail_bytes: config.tool_output_spill_tail_bytes,
}
}
pub fn is_enabled(&self) -> bool {
self.threshold_bytes > 0
}
}
impl Default for ToolSpillPolicy {
fn default() -> Self {
Self::OFF
}
}
#[derive(Debug, Default)]
pub struct ToolSpillStats {
virtualised: AtomicU64,
original_bytes: AtomicU64,
preview_bytes: AtomicU64,
file_bytes: AtomicU64,
failure_warned: AtomicBool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ToolSpillTotals {
pub virtualised: u64,
pub original_bytes: u64,
pub preview_bytes: u64,
pub file_bytes: u64,
}
impl ToolSpillTotals {
pub fn kept_out_bytes(&self) -> u64 {
self.original_bytes.saturating_sub(self.preview_bytes)
}
}
impl ToolSpillStats {
pub fn totals(&self) -> ToolSpillTotals {
ToolSpillTotals {
virtualised: self.virtualised.load(Ordering::Relaxed),
original_bytes: self.original_bytes.load(Ordering::Relaxed),
preview_bytes: self.preview_bytes.load(Ordering::Relaxed),
file_bytes: self.file_bytes.load(Ordering::Relaxed),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SpillTarget {
Never,
Spill,
ReadFile(PathBuf),
}
impl SpillTarget {
pub fn classify(
runtime_name: &str,
origin: &crate::tools::ToolOrigin,
input: &Value,
cwd: Option<&Path>,
) -> Self {
use crate::tools::ToolOrigin;
match origin {
ToolOrigin::Builtin if runtime_name == "read" => {
let ranged = input.get("limit").is_some_and(|limit| !limit.is_null());
match input.get("path").and_then(Value::as_str) {
Some(path) if !ranged => {
SpillTarget::ReadFile(crate::tools::resolve_path_in(path, cwd))
}
_ => SpillTarget::Never,
}
}
ToolOrigin::Builtin if SPILL_BUILTINS.contains(&runtime_name) => SpillTarget::Spill,
ToolOrigin::Builtin | ToolOrigin::Unknown => SpillTarget::Never,
ToolOrigin::Extension { .. } | ToolOrigin::Mcp { .. } | ToolOrigin::Plugin { .. } => {
SpillTarget::Spill
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Virtualised {
pub preview: String,
pub original_bytes: usize,
pub spill_path: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct ToolSpill {
policy: ToolSpillPolicy,
dir: Option<PathBuf>,
stats: Arc<ToolSpillStats>,
file_cap: usize,
session_cap: u64,
}
impl Default for ToolSpill {
fn default() -> Self {
Self::off()
}
}
impl ToolSpill {
pub fn off() -> Self {
Self {
policy: ToolSpillPolicy::OFF,
dir: None,
stats: Arc::default(),
file_cap: SPILL_FILE_CAP_BYTES,
session_cap: SPILL_SESSION_CAP_BYTES,
}
}
pub fn new(
policy: ToolSpillPolicy,
root: &Path,
session_key: &str,
stats: Arc<ToolSpillStats>,
) -> Self {
let dir = agent_core::core::tool_spill::session_component(session_key)
.map(|component| root.join(component));
Self {
policy,
dir,
stats,
file_cap: SPILL_FILE_CAP_BYTES,
session_cap: SPILL_SESSION_CAP_BYTES,
}
}
#[cfg(test)]
pub(crate) fn with_caps(mut self, file_cap: usize, session_cap: u64) -> Self {
self.file_cap = file_cap;
self.session_cap = session_cap;
self
}
pub fn is_enabled(&self) -> bool {
self.policy.is_enabled()
}
pub fn policy(&self) -> ToolSpillPolicy {
self.policy
}
pub fn dir(&self) -> Option<&Path> {
self.dir.as_deref()
}
pub fn totals(&self) -> ToolSpillTotals {
self.stats.totals()
}
pub fn virtualise(
&self,
tool_use_id: &str,
target: &SpillTarget,
text: &str,
) -> Option<Virtualised> {
if !self.policy.is_enabled() || text.len() <= self.policy.threshold_bytes {
return None;
}
let cut = Cut::new(text, self.policy.head_bytes, self.policy.tail_bytes)?;
let virtualised = match target {
SpillTarget::Never => return None,
SpillTarget::ReadFile(path) => Virtualised {
preview: read_preview(text, &cut, path),
original_bytes: text.len(),
spill_path: None,
},
SpillTarget::Spill => {
let (path, capped) = match self.write_spill(tool_use_id, text) {
Ok(written) => written,
Err(error) => {
if !self.stats.failure_warned.swap(true, Ordering::Relaxed) {
tracing::warn!(
dir = ?self.dir,
%error,
"tool output spill failed; big results keep the legacy cut",
);
}
return None;
}
};
Virtualised {
preview: spill_preview(text, &cut, &path, capped.then_some(self.file_cap)),
original_bytes: text.len(),
spill_path: Some(path),
}
}
};
self.stats.virtualised.fetch_add(1, Ordering::Relaxed);
self.stats
.original_bytes
.fetch_add(text.len() as u64, Ordering::Relaxed);
self.stats
.preview_bytes
.fetch_add(virtualised.preview.len() as u64, Ordering::Relaxed);
tracing::debug!(
tool_use_id,
original_bytes = text.len(),
preview_bytes = virtualised.preview.len(),
spilled = virtualised.spill_path.is_some(),
"tool output virtualised",
);
Some(virtualised)
}
fn write_spill(&self, tool_use_id: &str, text: &str) -> std::io::Result<(PathBuf, bool)> {
let dir = self.dir.as_deref().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "no valid spill session dir")
})?;
let capped = text.len() > self.file_cap;
let body = if capped {
agent_core::truncate_str(text, self.file_cap)
} else {
text
};
let note = capped.then(|| {
format!(
"\n[spill file cut at {} of {} bytes]\n",
body.len(),
text.len()
)
});
let size = (body.len() + note.as_ref().map_or(0, String::len)) as u64;
let before = self.stats.file_bytes.fetch_add(size, Ordering::Relaxed);
if before.saturating_add(size) > self.session_cap {
self.stats.file_bytes.fetch_sub(size, Ordering::Relaxed);
return Err(std::io::Error::other(format!(
"session spill cap of {} bytes reached",
self.session_cap
)));
}
let written = (|| {
if let Some(root) = dir.parent() {
agent_core::core::private_fs::ensure_private_dir(root)?;
}
agent_core::core::private_fs::ensure_private_dir(dir)?;
let (path, mut file) = create_spill_file(dir, &file_stem(tool_use_id))?;
file.write_all(body.as_bytes())?;
if let Some(note) = ¬e {
file.write_all(note.as_bytes())?;
}
Ok::<_, std::io::Error>(path)
})();
match written {
Ok(path) => Ok((path, capped)),
Err(error) => {
self.stats.file_bytes.fetch_sub(size, Ordering::Relaxed);
Err(error)
}
}
}
}
fn file_stem(tool_use_id: &str) -> String {
let stem: String = tool_use_id
.chars()
.take(96)
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect();
if stem.is_empty() {
"tool".to_string()
} else {
stem
}
}
fn create_spill_file(dir: &Path, stem: &str) -> std::io::Result<(PathBuf, std::fs::File)> {
let mut last = None;
for n in 0..16 {
let name = if n == 0 {
format!("{stem}.txt")
} else {
format!("{stem}-{n}.txt")
};
let path = dir.join(name);
match create_new_private(&path) {
Ok(file) => return Ok((path, file)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => last = Some(e),
Err(e) => return Err(e),
}
}
Err(last.unwrap_or_else(|| std::io::Error::other("no free spill file name")))
}
fn create_new_private(path: &Path) -> std::io::Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options
.mode(agent_core::core::private_fs::FILE_MODE)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
}
options.open(path)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Cut {
head_end: usize,
tail_start: usize,
total_lines: usize,
omitted_first: usize,
omitted_last: usize,
}
impl Cut {
fn new(text: &str, head: usize, tail: usize) -> Option<Self> {
let len = text.len();
if head.saturating_add(tail) >= len {
return None;
}
let bytes = text.as_bytes();
let mut head_end = floor_char_boundary(text, head);
if let Some(nl) = bytes[..head_end].iter().rposition(|&b| b == b'\n') {
if 2 * (nl + 1) >= head_end {
head_end = nl + 1;
}
}
let mut tail_start = ceil_char_boundary(text, len - tail);
if tail_start < len && bytes[tail_start - 1] != b'\n' {
if let Some(off) = bytes[tail_start..].iter().position(|&b| b == b'\n') {
let line_start = tail_start + off + 1;
if 2 * (len - line_start) >= len - tail_start {
tail_start = line_start;
}
}
}
debug_assert!(head_end < tail_start, "head < len - tail <= tail_start");
let newlines = |range: std::ops::Range<usize>| {
bytes[range].iter().filter(|&&b| b == b'\n').count()
};
let before_omitted = newlines(0..head_end);
let within_omitted = newlines(head_end..tail_start - 1);
let after = newlines(tail_start - 1..len);
let total_lines =
before_omitted + within_omitted + after + usize::from(!text.ends_with('\n'));
Some(Self {
head_end,
tail_start,
total_lines,
omitted_first: before_omitted + 1,
omitted_last: before_omitted + within_omitted + 1,
})
}
fn head<'a>(&self, text: &'a str) -> &'a str {
&text[..self.head_end]
}
fn tail<'a>(&self, text: &'a str) -> &'a str {
&text[self.tail_start..]
}
fn omitted_lines(&self) -> usize {
self.omitted_last - self.omitted_first + 1
}
}
fn floor_char_boundary(s: &str, mut i: usize) -> usize {
i = i.min(s.len());
while !s.is_char_boundary(i) {
i -= 1;
}
i
}
fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
while i < s.len() && !s.is_char_boundary(i) {
i += 1;
}
i
}
fn assemble(text: &str, cut: &Cut, marker: &str, note: &str) -> String {
let (head, tail) = (cut.head(text), cut.tail(text));
let mut out = String::with_capacity(head.len() + tail.len() + marker.len() + note.len() + 8);
out.push_str(head);
if !head.is_empty() && !head.ends_with('\n') {
out.push('\n');
}
out.push_str(marker);
out.push('\n');
out.push_str(tail);
if !tail.ends_with('\n') {
out.push('\n');
}
out.push('\n');
out.push_str(note);
out
}
fn spill_preview(text: &str, cut: &Cut, path: &Path, capped_at: Option<usize>) -> String {
let (first, last) = (cut.omitted_first, cut.omitted_last);
let marker = format!("[... lines {first}-{last} omitted ...]");
let capped = capped_at
.map(|cap| format!(" The file keeps the first {cap} bytes."))
.unwrap_or_default();
let note = format!(
"[Output virtualised: {} bytes, {} lines; lines {first}-{last} are omitted above. \
Full output: {} (read it with offset={} limit={}, or grep it).{capped}]",
text.len(),
cut.total_lines,
path.display(),
first - 1,
cut.omitted_lines(),
);
assemble(text, cut, &marker, ¬e)
}
fn read_preview(text: &str, cut: &Cut, path: &Path) -> String {
let file_line_at = |pos: usize| -> Option<usize> {
let start = text[..pos].rfind('\n').map_or(0, |nl| nl + 1);
let digits = text[start..].split('\t').next()?;
digits.parse::<usize>().ok().filter(|n| *n > 0)
};
let file_range = file_line_at(cut.head_end)
.zip(file_line_at(cut.tail_start - 1))
.filter(|(first, last)| first <= last);
match file_range {
Some((first, last)) => {
let marker = format!("[... file lines {first}-{last} omitted ...]");
let note = format!(
"[Output virtualised: {} bytes; file lines {first}-{last} are omitted above. \
Read {} with offset={} limit={} to see them.]",
text.len(),
path.display(),
first - 1,
last - first + 1,
);
assemble(text, cut, &marker, ¬e)
}
None => {
let (first, last) = (cut.omitted_first, cut.omitted_last);
let marker = format!("[... lines {first}-{last} omitted ...]");
let note = format!(
"[Output virtualised: {} bytes, {} lines; lines {first}-{last} of this output \
are omitted above. Read {} with offset/limit to see the rest.]",
text.len(),
cut.total_lines,
path.display(),
);
assemble(text, cut, &marker, ¬e)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::ToolOrigin;
use serde_json::json;
fn policy(threshold: usize, head: usize, tail: usize) -> ToolSpillPolicy {
ToolSpillPolicy {
threshold_bytes: threshold,
head_bytes: head,
tail_bytes: tail,
}
}
fn spill_in(root: &Path, policy: ToolSpillPolicy) -> ToolSpill {
ToolSpill::new(policy, root, "sess-1", Arc::default())
}
fn lines(n: usize, width: usize) -> String {
(1..=n)
.map(|i| {
let label = format!("line {i:05} ");
format!("{label}{}\n", "x".repeat(width - label.len() - 1))
})
.collect()
}
#[test]
fn threshold_is_exclusive_at_exactly_and_virtualises_one_over() {
let tmp = tempfile::tempdir().unwrap();
let spill = spill_in(tmp.path(), policy(1000, 100, 100));
let exact = "a".repeat(1000);
assert_eq!(spill.virtualise("t1", &SpillTarget::Spill, &exact), None);
let over = "a".repeat(1001);
let v = spill.virtualise("t2", &SpillTarget::Spill, &over).expect("one over spills");
assert_eq!(v.original_bytes, 1001);
assert_eq!(std::fs::read_to_string(v.spill_path.unwrap()).unwrap(), over);
}
#[test]
fn nothing_to_omit_keeps_the_legacy_path() {
let tmp = tempfile::tempdir().unwrap();
let spill = spill_in(tmp.path(), policy(10, 600, 600));
assert_eq!(spill.virtualise("t", &SpillTarget::Spill, &"z".repeat(1200)), None);
assert!(!tmp.path().join("sess-1").exists(), "no file written");
}
#[test]
fn off_and_never_touch_nothing() {
let tmp = tempfile::tempdir().unwrap();
let big = lines(500, 80);
let off = ToolSpill::new(ToolSpillPolicy::OFF, tmp.path(), "sess-1", Arc::default());
assert_eq!(off.virtualise("t", &SpillTarget::Spill, &big), None);
assert_eq!(ToolSpill::off().virtualise("t", &SpillTarget::Spill, &big), None);
let on = spill_in(tmp.path(), policy(1024, 256, 256));
assert_eq!(on.virtualise("t", &SpillTarget::Never, &big), None);
assert!(std::fs::read_dir(tmp.path()).unwrap().next().is_none(), "nothing written");
assert_eq!(on.totals(), ToolSpillTotals::default());
}
#[test]
fn cuts_land_on_line_boundaries_and_name_the_omitted_lines() {
let tmp = tempfile::tempdir().unwrap();
let text = lines(200, 50); let spill = spill_in(tmp.path(), policy(4096, 1030, 520));
let v = spill.virtualise("toolu_x", &SpillTarget::Spill, &text).unwrap();
let head = lines(20, 50);
let tail: String = text[text.len() - 500..].to_string();
let path = tmp.path().join("sess-1").join("toolu_x.txt");
let expected = format!(
"{head}[... lines 21-190 omitted ...]\n{tail}\n\
[Output virtualised: 10000 bytes, 200 lines; lines 21-190 are omitted above. \
Full output: {} (read it with offset=20 limit=170, or grep it).]",
path.display()
);
assert_eq!(v.preview, expected);
assert!(tail.starts_with("line 00191 "));
let file = std::fs::read_to_string(&path).unwrap();
let gap: Vec<&str> = file.lines().skip(20).take(170).collect();
assert!(gap[0].starts_with("line 00021 ") && gap[169].starts_with("line 00190 "));
}
#[test]
fn one_huge_line_is_cut_mid_line_on_char_boundaries() {
let tmp = tempfile::tempdir().unwrap();
let text = "é🦀".repeat(5000); let spill = spill_in(tmp.path(), policy(1000, 1001, 999));
let v = spill.virtualise("t", &SpillTarget::Spill, &text).unwrap();
let cut = Cut::new(&text, 1001, 999).unwrap();
assert!(text.is_char_boundary(cut.head_end) && text.is_char_boundary(cut.tail_start));
assert!(cut.head_end <= 1001 && cut.head_end > 990, "{}", cut.head_end);
assert!(text.len() - cut.tail_start <= 999 && text.len() - cut.tail_start > 990);
assert!(v.preview.starts_with(cut.head(&text)));
assert!(v.preview.contains(&format!("\n[... lines 1-1 omitted ...]\n{}\n", cut.tail(&text))));
assert_eq!(cut.total_lines, 1);
}
#[test]
fn a_short_first_line_does_not_waste_the_head_budget() {
let text = format!("abc\n{}\n{}", "y".repeat(5000), lines(10, 40));
let cut = Cut::new(&text, 1000, 200).unwrap();
assert_eq!(cut.head_end, 1000);
assert_eq!(cut.omitted_first, 2, "line 2 is partly shown and partly omitted");
assert!(cut.tail(&text).starts_with("line 00006 "), "tail cut moved to a line start");
}
#[test]
fn preview_is_deterministic() {
let tmp = tempfile::tempdir().unwrap();
let text = lines(400, 64);
let spill = spill_in(tmp.path(), policy(12_288, 4096, 3072));
let first = spill.virtualise("toolu_same", &SpillTarget::Spill, &text).unwrap();
std::fs::remove_file(first.spill_path.as_ref().unwrap()).unwrap();
let again = spill_in(tmp.path(), policy(12_288, 4096, 3072));
let second = again.virtualise("toolu_same", &SpillTarget::Spill, &text).unwrap();
assert_eq!(first.preview.as_bytes(), second.preview.as_bytes());
let cut = Cut::new(&text, 4096, 3072).unwrap();
let path = tmp.path().join("p.txt");
assert_eq!(spill_preview(&text, &cut, &path, None), spill_preview(&text, &cut, &path, None));
}
#[test]
fn default_budgets_shrink_a_25kb_result_to_about_7kb() {
let tmp = tempfile::tempdir().unwrap();
let config = agent_core::config::SynapsConfig::default();
let spill = spill_in(tmp.path(), ToolSpillPolicy::from_config(&config));
let text = lines(320, 80); let v = spill.virtualise("toolu_big", &SpillTarget::Spill, &text).unwrap();
assert!(v.preview.len() < 7 * 1024 + 400, "{}", v.preview.len());
assert!(v.preview.len() > 6 * 1024, "{}", v.preview.len());
let totals = spill.totals();
assert_eq!(totals.virtualised, 1);
assert_eq!(totals.original_bytes, 25_600);
assert_eq!(totals.preview_bytes, v.preview.len() as u64);
assert_eq!(totals.file_bytes, 25_600);
assert_eq!(totals.kept_out_bytes(), 25_600 - v.preview.len() as u64);
}
#[cfg(unix)]
#[test]
fn spill_dirs_are_0700_and_files_0600() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("spill");
let spill = ToolSpill::new(policy(100, 10, 10), &root, "sess-1", Arc::default());
let v = spill.virtualise("t", &SpillTarget::Spill, &"q".repeat(500)).unwrap();
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&root), 0o700);
assert_eq!(mode(&root.join("sess-1")), 0o700);
assert_eq!(mode(v.spill_path.as_ref().unwrap()), 0o600);
}
#[test]
fn per_file_cap_cuts_the_file_with_a_note_and_says_so() {
let tmp = tempfile::tempdir().unwrap();
let spill = spill_in(tmp.path(), policy(100, 20, 60)).with_caps(1000, u64::MAX);
let text = lines(100, 50); let v = spill.virtualise("t", &SpillTarget::Spill, &text).unwrap();
let file = std::fs::read_to_string(v.spill_path.as_ref().unwrap()).unwrap();
assert!(file.starts_with(&text[..1000]));
assert!(file.ends_with("\n[spill file cut at 1000 of 5000 bytes]\n"), "{file:?}");
assert!(v.preview.ends_with(" The file keeps the first 1000 bytes.]"));
assert!(v.preview.contains("line 00100 "));
}
#[test]
fn session_cap_falls_back_to_the_legacy_cut() {
let tmp = tempfile::tempdir().unwrap();
let spill = spill_in(tmp.path(), policy(100, 20, 20)).with_caps(usize::MAX, 1500);
let text = "w".repeat(1000);
assert!(spill.virtualise("a", &SpillTarget::Spill, &text).is_some());
assert_eq!(spill.virtualise("b", &SpillTarget::Spill, &text), None, "over the cap");
assert_eq!(spill.totals().file_bytes, 1000);
assert!(!tmp.path().join("sess-1/b.txt").exists());
}
#[test]
fn unwritable_or_invalid_dirs_fall_back_instead_of_pointing_nowhere() {
let tmp = tempfile::tempdir().unwrap();
let blocker = tmp.path().join("blocker");
std::fs::write(&blocker, "x").unwrap();
let spill = ToolSpill::new(policy(100, 10, 10), &blocker, "sess-1", Arc::default());
assert_eq!(spill.virtualise("t", &SpillTarget::Spill, &"q".repeat(500)), None);
assert_eq!(spill.totals(), ToolSpillTotals::default());
let spill = ToolSpill::new(policy(100, 10, 10), tmp.path(), "../escape", Arc::default());
assert_eq!(spill.dir(), None);
assert_eq!(spill.virtualise("t", &SpillTarget::Spill, &"q".repeat(500)), None);
}
#[test]
fn reused_tool_use_ids_never_overwrite_an_earlier_spill() {
let tmp = tempfile::tempdir().unwrap();
let spill = spill_in(tmp.path(), policy(100, 10, 10));
let a = spill.virtualise("call_0", &SpillTarget::Spill, &"a".repeat(300)).unwrap();
let b = spill.virtualise("call_0", &SpillTarget::Spill, &"b".repeat(300)).unwrap();
let c = spill.virtualise("call/0", &SpillTarget::Spill, &"c".repeat(300)).unwrap();
let name = |v: &Virtualised| v.spill_path.as_ref().unwrap().file_name().unwrap().to_owned();
assert_eq!(name(&a), "call_0.txt");
assert_eq!(name(&b), "call_0-1.txt");
assert_eq!(name(&c), "call_0-2.txt", "sanitized alike: still a new file");
assert_eq!(std::fs::read_to_string(a.spill_path.unwrap()).unwrap(), "a".repeat(300));
assert_eq!(file_stem(""), "tool");
assert_eq!(file_stem("toolu_01AbC-9"), "toolu_01AbC-9");
}
#[test]
fn read_points_back_at_the_file_with_an_exact_offset_and_limit() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("big.rs");
let body: String = (1..=500).map(|i| format!("fn line_{i:03}() {{ /* {} */ }}\n", "r".repeat(40))).collect();
std::fs::write(&file, &body).unwrap();
let text: String = body.lines().enumerate().map(|(i, l)| format!("{}\t{l}\n", i + 1)).collect();
let spill = spill_in(tmp.path(), policy(12_288, 4096, 3072));
let target = SpillTarget::ReadFile(file.clone());
let v = spill.virtualise("toolu_r", &target, &text).unwrap();
assert_eq!(v.spill_path, None, "read is never copied");
assert!(!tmp.path().join("sess-1").exists(), "no spill dir either");
let cut = Cut::new(&text, 4096, 3072).unwrap();
let first: usize = text.lines().nth(cut.omitted_first - 1).unwrap().split('\t').next().unwrap().parse().unwrap();
let last: usize = text.lines().nth(cut.omitted_last - 1).unwrap().split('\t').next().unwrap().parse().unwrap();
assert!(v.preview.contains(&format!("[... file lines {first}-{last} omitted ...]")));
assert!(v.preview.ends_with(&format!(
"file lines {first}-{last} are omitted above. Read {} with offset={} limit={} to see them.]",
file.display(),
first - 1,
last - first + 1
)));
let omitted: Vec<&str> = body.lines().skip(first - 1).take(last - first + 1).collect();
assert!(omitted[0].starts_with(&format!("fn line_{first:03}")));
assert!(!v.preview.contains(omitted[0]));
assert_eq!(spill.totals().file_bytes, 0);
}
#[test]
fn read_output_without_line_numbers_still_points_at_the_file() {
let tmp = tempfile::tempdir().unwrap();
let spill = spill_in(tmp.path(), policy(1000, 100, 100));
let v = spill
.virtualise("t", &SpillTarget::ReadFile(PathBuf::from("/p/x.log")), &lines(100, 40))
.unwrap();
assert!(v.preview.ends_with(
"of this output are omitted above. Read /p/x.log with offset/limit to see the rest.]"
));
}
#[test]
fn classify_spills_output_dumps_and_extension_mcp_tools_only() {
let builtin = ToolOrigin::Builtin;
let none = json!({});
for name in ["bash", "grep", "find", "ls", "shell_send", "subagent_collect"] {
assert_eq!(SpillTarget::classify(name, &builtin, &none, None), SpillTarget::Spill, "{name}");
}
for name in ["edit", "write", "multi_edit", "load_skill", "memory_fetch", "respond"] {
assert_eq!(SpillTarget::classify(name, &builtin, &none, None), SpillTarget::Never, "{name}");
}
let mcp = ToolOrigin::Mcp {
server_id: "s".into(),
server_tool_name: "t".into(),
};
let ext = ToolOrigin::Extension {
extension_id: "e".into(),
};
let plugin = ToolOrigin::Plugin {
plugin_id: "p".into(),
tool_name: "t".into(),
};
for origin in [&mcp, &ext, &plugin] {
assert_eq!(SpillTarget::classify("read", origin, &none, None), SpillTarget::Spill);
}
assert_eq!(SpillTarget::classify("bash", &ToolOrigin::Unknown, &none, None), SpillTarget::Never);
let cwd = Path::new("/work");
assert_eq!(
SpillTarget::classify("read", &builtin, &json!({"path": "src/a.rs", "offset": 10}), Some(cwd)),
SpillTarget::ReadFile(PathBuf::from("/work/src/a.rs"))
);
assert_eq!(
SpillTarget::classify("read", &builtin, &json!({"path": "a.rs", "limit": 900}), Some(cwd)),
SpillTarget::Never
);
assert_eq!(
SpillTarget::classify("read", &builtin, &json!({"path": "a.rs", "limit": null}), Some(cwd)),
SpillTarget::ReadFile(PathBuf::from("/work/a.rs"))
);
assert_eq!(SpillTarget::classify("read", &builtin, &none, None), SpillTarget::Never);
}
}