use serde_json::Value;
use vtcode_core::config::constants::tools as tool_names;
use vtcode_core::tools::tool_intent;
pub(crate) fn read_file_path_arg(args: &Value) -> Option<&str> {
let obj = args.as_object()?;
for key in ["path", "file_path", "filepath", "target_path", "file"] {
if let Some(path) = obj.get(key).and_then(Value::as_str) {
let trimmed = path.trim();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
}
None
}
pub(crate) fn is_read_file_style_call(canonical_tool_name: &str, args: &Value) -> bool {
match canonical_tool_name {
tool_names::READ_FILE => true,
tool_names::UNIFIED_FILE => tool_intent::file_operation_action(args)
.unwrap_or("read")
.eq_ignore_ascii_case("read"),
_ => false,
}
}
fn looks_like_tool_output_spool_path(path: &str) -> bool {
let normalized = path.replace('\\', "/");
let components: Vec<_> = normalized.split('/').filter(|part| !part.is_empty() && *part != ".").collect();
if components.contains(&"..") {
return false;
}
components
.windows(4)
.any(|parts| parts.starts_with(&[".vtcode", "context", "tool_outputs"]))
}
pub(crate) fn spool_chunk_read_path<'a>(canonical_tool_name: &str, args: &'a Value) -> Option<&'a str> {
if !is_read_file_style_call(canonical_tool_name, args) {
return None;
}
let path = read_file_path_arg(args)?;
if looks_like_tool_output_spool_path(path) {
Some(path)
} else {
None
}
}
const SPOOL_PAGE_SHELL_COMMANDS: &[&str] = &["sed", "cat", "head", "tail", "bat", "rg", "grep"];
pub(crate) fn exec_spool_page_source(canonical_tool_name: &str, args: &Value) -> Option<String> {
if !matches!(canonical_tool_name, tool_names::UNIFIED_EXEC | tool_names::EXEC_COMMAND | "command_session") {
return None;
}
let words = vtcode_core::tools::command_args::command_words(args).ok()??;
if words.iter().any(|word| {
matches!(word.as_str(), "&&" | "||" | ";") || word.ends_with(';') || word.contains("&&") || word.contains("||")
}) {
return None;
}
let words_after_env = vtcode_core::tools::command_args::command_words_after_environment_prefix(&words);
let first = words_after_env.first()?;
let base = first
.rsplit('/')
.next()
.unwrap_or(first)
.trim_matches(|ch| ch == '\'' || ch == '"')
.to_ascii_lowercase();
if !SPOOL_PAGE_SHELL_COMMANDS.contains(&base.as_str()) {
return None;
}
words
.iter()
.find(|word| looks_like_tool_output_spool_path(word.as_str()))
.cloned()
}
pub(crate) fn spool_page_source_path(canonical_tool_name: &str, args: &Value) -> Option<String> {
spool_chunk_read_path(canonical_tool_name, args)
.map(str::to_string)
.or_else(|| exec_spool_page_source(canonical_tool_name, args))
}
pub(crate) async fn read_spool_head_for_error_check(workspace: &std::path::Path, path: &str) -> Option<String> {
let workspace = workspace.to_path_buf();
let path = std::path::PathBuf::from(path);
tokio::task::spawn_blocking(move || -> std::io::Result<String> {
use std::io::Read;
let relative = if path.is_absolute() {
path.strip_prefix(&workspace)
.map_err(|error| std::io::Error::new(std::io::ErrorKind::PermissionDenied, error))?
} else {
path.as_path()
};
if !relative.starts_with(".vtcode/context/tool_outputs") {
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "not a workspace spool"));
}
let file = vtcode_commons::fs::bound_file::open_file_beneath(&workspace, relative)?;
let mut buffer = Vec::new();
file.take(4096).read_to_end(&mut buffer)?;
Ok(String::from_utf8_lossy(&buffer).into_owned())
})
.await
.ok()?
.ok()
.filter(|head| !head.is_empty())
}
pub(crate) fn spool_content_looks_like_error(content: &str) -> bool {
let trimmed = content.trim_start();
if trimmed.starts_with("{\"error\"") {
return true;
}
if trimmed.starts_with("{\"output\":\"Outline requires ast-grep") {
return true;
}
if trimmed.starts_with("{\"output\":\"Error") {
return true;
}
let lower = trimmed.to_ascii_lowercase();
lower.starts_with("error:")
|| lower.starts_with("tool error:")
|| lower.starts_with("ast-grep")
|| lower.contains("outline requires ast-grep")
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vtcode_core::config::constants::tools;
use super::{
exec_spool_page_source, spool_chunk_read_path, spool_content_looks_like_error, spool_page_source_path,
};
#[test]
fn spool_path_requires_components_not_substrings() {
for path in [
"fake.vtcode/context/tool_outputs/file",
".vtcode/context/tool_outputs/../secret",
".vtcode/context/tool_outputs",
] {
assert_eq!(spool_chunk_read_path(tools::READ_FILE, &json!({"path": path})), None);
}
}
#[test]
fn spool_chunk_read_path_matches_read_file_spool_reads() {
let args = json!({
"path": ".vtcode/context/tool_outputs/command_session_123.txt",
"offset": 41,
"limit": 40
});
assert_eq!(
spool_chunk_read_path(tools::READ_FILE, &args),
Some(".vtcode/context/tool_outputs/command_session_123.txt")
);
}
#[test]
fn spool_chunk_read_path_matches_file_operation_read_spool_reads() {
let args = json!({
"action": "read",
"path": ".vtcode/context/tool_outputs/command_session_456.txt",
"offset": 81,
"limit": 40
});
assert_eq!(
spool_chunk_read_path(tools::UNIFIED_FILE, &args),
Some(".vtcode/context/tool_outputs/command_session_456.txt")
);
}
#[test]
fn spool_chunk_read_path_ignores_regular_reads() {
let args = json!({
"path": "src/main.rs",
"offset": 1,
"limit": 100
});
assert_eq!(spool_chunk_read_path(tools::READ_FILE, &args), None);
}
#[test]
fn spool_chunk_read_path_ignores_non_read_file_operation_actions() {
let args = json!({
"action": "write",
"path": ".vtcode/context/tool_outputs/command_session_789.txt",
"content": "replacement"
});
assert_eq!(spool_chunk_read_path(tools::UNIFIED_FILE, &args), None);
}
#[test]
fn spool_content_looks_like_error_detects_json_error_envelope() {
let payload = r#"{"error":"Spool chunk reads exceeded","failure_kind":"spool_chunk_guard"}"#;
assert!(spool_content_looks_like_error(payload));
}
#[test]
fn spool_content_looks_like_error_detects_outline_ast_grep_failure() {
let payload = r#"{"output":"Outline requires ast-grep (`sg`). not installed"}"#;
assert!(spool_content_looks_like_error(payload));
}
#[test]
fn spool_content_looks_like_error_detects_plain_error_prefix() {
assert!(spool_content_looks_like_error("Error: file not found"));
assert!(spool_content_looks_like_error("ast-grep: command not found"));
}
#[test]
fn spool_content_looks_like_error_returns_false_for_normal_output() {
assert!(!spool_content_looks_like_error("approval_recorder.rs\nassembly.rs\nbuilder.rs"));
assert!(!spool_content_looks_like_error("=== mod.rs ===\npub struct ToolRegistry"));
}
#[test]
fn exec_spool_page_source_matches_sed_range_on_spool() {
let args = json!({"cmd": "sed -n '320,520p' .vtcode/context/tool_outputs/write_stdin_run-abc123.txt"});
assert_eq!(
exec_spool_page_source(tools::EXEC_COMMAND, &args),
Some(".vtcode/context/tool_outputs/write_stdin_run-abc123.txt".to_string())
);
}
#[test]
fn exec_spool_page_source_matches_rg_on_spool() {
let args = json!({"cmd": "rg -n StartupContext .vtcode/context/tool_outputs/write_stdin_run-abc123.txt"});
assert_eq!(
exec_spool_page_source(tools::UNIFIED_EXEC, &args),
Some(".vtcode/context/tool_outputs/write_stdin_run-abc123.txt".to_string())
);
}
#[test]
fn exec_spool_page_source_ignores_non_spool_and_non_paging_commands() {
let sed_source = json!({"cmd": "sed -n '1,80p' src/main.rs"});
assert_eq!(exec_spool_page_source(tools::EXEC_COMMAND, &sed_source), None);
let check = json!({"cmd": "cargo check --locked"});
assert_eq!(exec_spool_page_source(tools::EXEC_COMMAND, &check), None);
let read = json!({"path": ".vtcode/context/tool_outputs/write_stdin_run-abc123.txt"});
assert_eq!(exec_spool_page_source(tools::READ_FILE, &read), None);
}
#[test]
fn exec_spool_page_source_rejects_sequenced_piggyback_commands() {
for cmd in [
"sed -n '1,80p' .vtcode/context/tool_outputs/write_stdin_run-abc123.txt && cat src/main.rs",
"cat .vtcode/context/tool_outputs/write_stdin_run-abc123.txt; cat src/main.rs",
"cat .vtcode/context/tool_outputs/write_stdin_run-abc123.txt || cat src/main.rs",
] {
assert_eq!(
exec_spool_page_source(tools::EXEC_COMMAND, &json!({"cmd": cmd})),
None,
"sequenced command must not count as spool paging: {cmd}"
);
}
let piped = json!({"cmd": "cat .vtcode/context/tool_outputs/write_stdin_run-abc123.txt | head -40"});
assert_eq!(
exec_spool_page_source(tools::EXEC_COMMAND, &piped),
Some(".vtcode/context/tool_outputs/write_stdin_run-abc123.txt".to_string())
);
}
#[test]
fn exec_spool_page_source_strips_env_prefix() {
let args = json!({"cmd": "env sed -n '1,80p' .vtcode/context/tool_outputs/write_stdin_run-abc123.txt"});
assert_eq!(
exec_spool_page_source(tools::EXEC_COMMAND, &args),
Some(".vtcode/context/tool_outputs/write_stdin_run-abc123.txt".to_string())
);
}
#[test]
fn spool_page_source_path_unifies_both_channels() {
let read = json!({"path": ".vtcode/context/tool_outputs/write_stdin_run-abc123.txt"});
assert_eq!(
spool_page_source_path(tools::READ_FILE, &read),
Some(".vtcode/context/tool_outputs/write_stdin_run-abc123.txt".to_string())
);
let sed = json!({"cmd": "sed -n '1,80p' .vtcode/context/tool_outputs/write_stdin_run-abc123.txt"});
assert_eq!(
spool_page_source_path(tools::EXEC_COMMAND, &sed),
Some(".vtcode/context/tool_outputs/write_stdin_run-abc123.txt".to_string())
);
let other = json!({"cmd": "sed -n '1,80p' src/main.rs"});
assert_eq!(spool_page_source_path(tools::EXEC_COMMAND, &other), None);
}
}