use super::{
ToolResult, ToolResultDisplay, ToolRuntime,
args::{
FILE_READ_MAX_BYTES, FILE_READ_MAX_FILES, FILE_READ_MAX_LINES, FILE_WRITE_MAX_BYTES,
ReadArgs, WriteArgs,
},
contract::{metadata_key as meta, tool_name},
github_resource::{GitHubResourceKind, fetch_github_resource},
hash_edit::{
format::{compute_file_hash, format_hashline_header, format_numbered_lines},
normalize::normalize_for_snapshot_storage,
},
read_selector::{LineSelector, ReadSelector, split_selector_suffix},
url_fetch::{UrlFetchInput, fetch_url},
};
use crate::{
agent::cancellation::AgentCancellation,
checkpoints::{
SnapshotEligibility, SnapshotTool, capture_file_snapshot, classify_snapshot_eligibility,
},
persistence::{CrossProcessFileLock, atomic_write},
};
use serde_json::{Map, Value, json};
use std::{
fs,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
#[derive(Debug, Clone, Copy)]
pub(super) struct ExistingPathPolicy {
allow_absolute_paths: bool,
setting_name: &'static str,
}
impl ExistingPathPolicy {
pub(super) fn read(allow_absolute_paths: bool) -> Self {
Self {
allow_absolute_paths,
setting_name: "tools.read.absolute_paths",
}
}
pub(super) fn view_image(allow_absolute_paths: bool) -> Self {
Self {
allow_absolute_paths,
setting_name: "tools.view_image.absolute_paths",
}
}
pub(super) fn hash_edit(allow_absolute_paths: bool) -> Self {
Self {
allow_absolute_paths,
setting_name: "tools.hash_edit.absolute_paths",
}
}
pub(super) fn grep(allow_absolute_paths: bool) -> Self {
Self {
allow_absolute_paths,
setting_name: "tools.grep.absolute_paths",
}
}
pub(super) fn find(allow_absolute_paths: bool) -> Self {
Self {
allow_absolute_paths,
setting_name: "tools.find.absolute_paths",
}
}
pub(super) fn list_files(allow_absolute_paths: bool) -> Self {
Self {
allow_absolute_paths,
setting_name: "tools.list_files.absolute_paths",
}
}
pub(super) fn repo_map(allow_absolute_paths: bool) -> Self {
Self {
allow_absolute_paths,
setting_name: "tools.repo_map.absolute_paths",
}
}
pub(super) fn ast_grep(allow_absolute_paths: bool) -> Self {
Self {
allow_absolute_paths,
setting_name: "tools.ast_grep.absolute_paths",
}
}
pub(super) fn lsp() -> Self {
Self {
allow_absolute_paths: false,
setting_name: "lsp.workspace_root",
}
}
}
impl ToolRuntime {
pub(super) fn resolve_existing_path(
&self,
path: &str,
policy: ExistingPathPolicy,
) -> anyhow::Result<PathBuf> {
let user_path = PathBuf::from(path);
let is_absolute = user_path.is_absolute();
let candidate = if is_absolute {
user_path
} else {
self.cwd.join(user_path)
};
let canonical = candidate.canonicalize()?;
if !is_absolute || !policy.allow_absolute_paths {
self.ensure_inside_with_setting(&canonical, policy.setting_name)?;
}
Ok(canonical)
}
pub(super) fn resolve_existing_for_read(&self, path: &str) -> anyhow::Result<PathBuf> {
self.resolve_existing_path(path, ExistingPathPolicy::read(self.read_absolute_paths))
}
pub(super) fn resolve_for_write(&self, path: &str) -> anyhow::Result<PathBuf> {
let user_path = PathBuf::from(path);
let is_absolute = user_path.is_absolute();
let allow_absolute = self.write_absolute_paths && is_absolute;
let candidate = if is_absolute {
user_path
} else {
self.cwd.join(user_path)
};
if let Ok(metadata) = fs::symlink_metadata(&candidate) {
if metadata.file_type().is_symlink() {
anyhow::bail!(
"write target '{}' must not be a symlink",
candidate.display()
);
}
let canonical = candidate.canonicalize()?;
if !allow_absolute {
self.ensure_inside_with_setting(&canonical, "tools.write.absolute_paths")?;
}
return Ok(canonical);
}
let normalized = lexical_normalize(&candidate);
if !allow_absolute && !normalized.starts_with(&self.cwd_canonical) {
anyhow::bail!(
"path '{}' escapes cwd '{}' (set tools.write.absolute_paths=true to allow absolute paths)",
normalized.display(),
self.cwd_canonical.display()
);
}
let parent = normalized
.parent()
.ok_or_else(|| anyhow::anyhow!("path has no parent"))?;
let existing_parent = nearest_existing_ancestor(parent)?;
let parent_canonical = existing_parent.canonicalize()?;
if !allow_absolute {
self.ensure_inside_with_setting(&parent_canonical, "tools.write.absolute_paths")?;
}
Ok(normalized)
}
pub(super) fn ensure_inside_with_setting(
&self,
canonical: &Path,
setting_name: &str,
) -> anyhow::Result<()> {
if !canonical.starts_with(&self.cwd_canonical) {
anyhow::bail!(
"path '{}' escapes cwd '{}' ({} is false)",
canonical.display(),
self.cwd_canonical.display(),
setting_name
);
}
Ok(())
}
fn ensure_parent_inside_after_creation(
&self,
parent: &Path,
allow_absolute_paths: bool,
) -> anyhow::Result<()> {
let canonical = parent.canonicalize()?;
if allow_absolute_paths {
return Ok(());
}
self.ensure_inside_with_setting(&canonical, "tools.write.absolute_paths")
}
pub(super) fn file_lock(&self, path: &Path) -> anyhow::Result<Arc<Mutex<()>>> {
let key = path
.canonicalize()
.unwrap_or_else(|_| lexical_normalize(path));
let mut locks = self
.mutation_locks
.lock()
.map_err(|_| anyhow::anyhow!("tool mutation lock registry mutex poisoned"))?;
Ok(locks
.entry(key)
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone())
}
pub(super) fn read(
&self,
args: ReadArgs,
cancellation: &AgentCancellation,
) -> anyhow::Result<ToolResult> {
let offset = args.offset.unwrap_or(1);
let limit = args.limit.unwrap_or(FILE_READ_MAX_LINES);
if !args.is_multi_file() {
let requested_path = args
.requested_paths()
.first()
.ok_or_else(|| anyhow::anyhow!("read requires paths"))?;
let resource = self.read_one_resource(requested_path, offset, limit, cancellation)?;
let mut metadata = resource.metadata;
metadata.insert(meta::OFFSET.to_string(), json!(offset));
metadata.insert(meta::LIMIT.to_string(), json!(limit));
metadata.insert(meta::BYTE_LIMIT.to_string(), json!(FILE_READ_MAX_BYTES));
metadata.insert(meta::LINE_LIMIT.to_string(), json!(FILE_READ_MAX_LINES));
return Ok(ToolResult {
tool_name: tool_name::READ.to_string(),
success: true,
content: resource.content,
metadata: Value::Object(metadata),
display: ToolResultDisplay::default(),
});
}
let requested_paths = args.requested_paths();
let mut output = String::new();
let mut summaries = Vec::with_capacity(requested_paths.len());
let mut succeeded = 0usize;
let mut failed = 0usize;
let mut truncated = false;
let mut aggregate_truncated = false;
for requested_path in requested_paths {
match self.read_one_resource(requested_path, offset, limit, cancellation) {
Ok(resource) => {
let body = resource.content;
if append_multi_read_section(&mut output, requested_path, &body) {
succeeded += 1;
truncated |= resource.truncated;
let mut summary = resource.metadata;
summary.insert("requested_path".to_string(), json!(requested_path));
summary.insert("success".to_string(), json!(true));
summaries.push(Value::Object(summary));
} else {
failed += 1;
aggregate_truncated = true;
truncated = true;
summaries.push(json!({
"requested_path": requested_path,
"success": false,
"error": "aggregate read output limit exceeded",
}));
break;
}
}
Err(error) => {
let error_text = format!("ERROR: {error}");
if !append_multi_read_section(&mut output, requested_path, &error_text) {
aggregate_truncated = true;
truncated = true;
summaries.push(json!({
"requested_path": requested_path,
"success": false,
"error": "aggregate read output limit exceeded",
}));
failed += 1;
break;
}
failed += 1;
summaries.push(json!({
"requested_path": requested_path,
"success": false,
"error": error.to_string(),
}));
}
}
}
Ok(ToolResult {
tool_name: tool_name::READ.to_string(),
success: failed == 0 && !aggregate_truncated,
content: output,
metadata: json!({
(meta::PATHS): requested_paths,
(meta::FILES): requested_paths.len(),
(meta::SUCCEEDED): succeeded,
(meta::FAILED): failed,
(meta::OFFSET): offset,
(meta::LIMIT): limit,
(meta::BYTE_LIMIT): FILE_READ_MAX_BYTES,
(meta::LINE_LIMIT): FILE_READ_MAX_LINES,
"file_limit": FILE_READ_MAX_FILES,
(meta::TRUNCATED): truncated,
"aggregate_truncated": aggregate_truncated,
"results": summaries,
}),
display: ToolResultDisplay::default(),
})
}
fn read_one_resource(
&self,
requested: &str,
offset: usize,
limit: usize,
cancellation: &AgentCancellation,
) -> anyhow::Result<ReadResourceOutput> {
cancellation.check()?;
let (target, selector) = self.split_read_target(requested)?;
let mut text = self.resolve_read_text(target, cancellation)?;
let selector_used = !selector.is_empty();
let mut hashline_seen_lines = None;
let (content, total_lines, selector_truncated) = if text.kind == "file" && !selector.raw {
let normalized = normalize_for_snapshot_storage(&text.content);
let selection = select_file_lines(&normalized, offset, limit, selector);
let path = metadata_path(&text.metadata)?;
let formatted = format_hashline_read(&path, &normalized, &selection.lines)?;
hashline_seen_lines = Some(formatted.seen_lines);
(
formatted.content,
selection.total_lines,
selection.truncated || formatted.truncated,
)
} else if selector_used {
selector.apply(&text.content)
} else if text.kind == "file" {
apply_legacy_slice(&text.content, offset, limit)
} else {
selector.apply(&text.content)
};
if text.kind == "file" && !selector.raw {
let normalized = normalize_for_snapshot_storage(&text.content);
let path = metadata_path(&text.metadata)?;
let seen_lines = hashline_seen_lines.unwrap_or_default();
let tag = self
.hashline_snapshots
.lock()
.map_err(|_| anyhow::anyhow!("hashline snapshot store mutex poisoned"))?
.record(&path, normalized, seen_lines.clone());
text.metadata.insert("hashline_tag".to_string(), json!(tag));
text.metadata
.insert("hashline_seen_lines".to_string(), json!(seen_lines));
}
text.metadata
.insert(meta::TOTAL_LINES.to_string(), json!(total_lines));
text.metadata.insert(
meta::TRUNCATED.to_string(),
json!(text.truncated || selector_truncated),
);
text.metadata
.insert("source".to_string(), json!(text.source));
text.metadata
.insert(meta::KIND.to_string(), json!(text.kind));
text.metadata
.insert("selector".to_string(), selector.metadata());
text.metadata.insert("raw".to_string(), json!(selector.raw));
if selector_used && (offset != 1 || limit != FILE_READ_MAX_LINES) {
text.metadata
.insert("legacy_slice_ignored".to_string(), json!(true));
}
Ok(ReadResourceOutput {
content,
metadata: text.metadata,
truncated: text.truncated || selector_truncated,
})
}
fn split_read_target<'a>(&self, requested: &'a str) -> anyhow::Result<(&'a str, ReadSelector)> {
if requested.starts_with("http://") || requested.starts_with("https://") {
if let Ok(parsed) = reqwest::Url::parse(requested)
&& parsed.port().is_some()
&& parsed.path() == "/"
&& parsed.query().is_none()
{
return Ok((requested, ReadSelector::default()));
}
let split = split_selector_suffix(requested)?;
return Ok((split.target, split.selector));
}
if is_url_or_scheme(requested) {
let split = split_selector_suffix(requested)?;
return Ok((split.target, split.selector));
}
if self.resolve_existing_for_read(requested).is_ok() {
return Ok((requested, ReadSelector::default()));
}
let split = split_selector_suffix(requested)?;
if split.selector.is_empty() {
return Ok((requested, ReadSelector::default()));
}
Ok((split.target, split.selector))
}
fn resolve_read_text(
&self,
target: &str,
cancellation: &AgentCancellation,
) -> anyhow::Result<ReadText> {
if target.starts_with("http://") || target.starts_with("https://") {
return self.read_url(target, cancellation);
}
if let Some(resource) = target.strip_prefix("skill://") {
if resource.is_empty() {
anyhow::bail!("skill:// resource requires a skill name");
}
let (name, reference) = resource
.split_once('/')
.map_or((resource, None), |(name, reference)| {
(name, Some(reference))
});
if name.is_empty() {
anyhow::bail!("skill:// resource requires a skill name");
}
let result = if let Some(reference) = reference {
self.load_skill_reference(name, reference)?
} else {
self.load_skill_markdown(name)?
};
let source = if let Some(reference) = result.reference.as_deref() {
format!("skill://{name}/{reference}")
} else {
format!("skill://{name}")
};
return Ok(ReadText {
source,
kind: result.kind,
content: result.content,
metadata: result.metadata,
truncated: false,
});
}
if let Some(id) = target.strip_prefix("session://") {
return self.read_session(id);
}
if let Some(id) = target.strip_prefix("issue://") {
let content = fetch_github_resource(GitHubResourceKind::Issue, id, cancellation)?;
let mut metadata = Map::new();
metadata.insert("issue".to_string(), json!(id));
metadata.insert(meta::BYTES.to_string(), json!(content.len()));
return Ok(ReadText {
source: format!("issue://{id}"),
kind: "issue",
content,
metadata,
truncated: false,
});
}
if let Some(id) = target.strip_prefix("pr://") {
let content = fetch_github_resource(GitHubResourceKind::Pr, id, cancellation)?;
let mut metadata = Map::new();
metadata.insert("pr".to_string(), json!(id));
metadata.insert(meta::BYTES.to_string(), json!(content.len()));
return Ok(ReadText {
source: format!("pr://{id}"),
kind: "pr",
content,
metadata,
truncated: false,
});
}
if target.contains("://") {
anyhow::bail!("unsupported read resource scheme");
}
self.read_file_text(target)
}
fn read_file_text(&self, requested_path: &str) -> anyhow::Result<ReadText> {
let path = self.resolve_existing_for_read(requested_path)?;
let byte_len = fs::metadata(&path)?.len();
if byte_len > FILE_READ_MAX_BYTES {
anyhow::bail!("file is {byte_len} bytes; read limit is {FILE_READ_MAX_BYTES} bytes");
}
let content = fs::read_to_string(&path)?;
let mut metadata = Map::new();
metadata.insert(meta::PATH.to_string(), json!(path));
metadata.insert(meta::BYTES.to_string(), json!(byte_len));
Ok(ReadText {
source: requested_path.to_string(),
kind: "file",
content,
metadata,
truncated: false,
})
}
fn read_session(&self, id: &str) -> anyhow::Result<ReadText> {
if id.is_empty() {
anyhow::bail!("session:// resource requires a session id");
}
let session = self.session_manager.open(id.to_string())?;
let path = session.path();
let byte_len = fs::metadata(path)?.len();
if byte_len > FILE_READ_MAX_BYTES {
anyhow::bail!("session is {byte_len} bytes; read limit is {FILE_READ_MAX_BYTES} bytes");
}
let content = fs::read_to_string(path)?;
let mut metadata = Map::new();
metadata.insert("session_id".to_string(), json!(session.id()));
metadata.insert(meta::BYTES.to_string(), json!(byte_len));
metadata.insert("records".to_string(), json!(content.lines().count()));
Ok(ReadText {
source: format!("session://{}", session.id()),
kind: "session",
content,
metadata,
truncated: false,
})
}
fn read_url(&self, url: &str, cancellation: &AgentCancellation) -> anyhow::Result<ReadText> {
let result = fetch_url(
UrlFetchInput {
url: url.to_string(),
max_tokens: None,
}
.validate()?,
cancellation,
)?;
if !result.success {
anyhow::bail!(result.content);
}
let mut metadata = Map::new();
if let Some(object) = result.metadata.as_object() {
metadata.extend(object.clone());
}
Ok(ReadText {
source: url.to_string(),
kind: "url",
content: result.content,
truncated: result
.metadata
.get(meta::TRUNCATED)
.and_then(Value::as_bool)
.unwrap_or(false),
metadata,
})
}
pub(super) fn write_file(
&self,
args: WriteArgs,
cancellation: &AgentCancellation,
) -> anyhow::Result<ToolResult> {
cancellation.check()?;
if args.content.len() > FILE_WRITE_MAX_BYTES {
anyhow::bail!(
"write content is {} bytes; limit is {FILE_WRITE_MAX_BYTES} bytes",
args.content.len()
);
}
let path = self.resolve_for_write(&args.path)?;
let lock = self.file_lock(&path)?;
let _guard = lock
.lock()
.map_err(|_| anyhow::anyhow!("file mutation lock poisoned for '{}'", path.display()))?;
let _cross_process_guard = CrossProcessFileLock::acquire(&path)?;
let pre_snapshot = self.pre_snapshot_bytes(&path)?;
if let Some(parent) = path.parent() {
cancellation.check()?;
fs::create_dir_all(parent)?;
self.ensure_parent_inside_after_creation(
parent,
self.write_absolute_paths && PathBuf::from(&args.path).is_absolute(),
)?;
}
cancellation.check()?;
atomic_write(&path, args.content.as_bytes())?;
self.record_file_snapshot(
SnapshotTool::WriteFile,
&path,
pre_snapshot.as_deref(),
Some(args.content.as_bytes()),
);
Ok(ToolResult {
tool_name: tool_name::WRITE.to_string(),
success: true,
content: format!("wrote {} bytes", args.content.len()),
metadata: json!({(meta::PATH): path, (meta::BYTES): args.content.len()}),
display: ToolResultDisplay::default(),
})
}
fn snapshot_eligible(&self, path: &Path) -> bool {
let Some(context) = self.checkpoint_context() else {
return false;
};
matches!(
classify_snapshot_eligibility(path, &self.cwd_canonical, &context.paths),
SnapshotEligibility::Eligible
)
}
pub(super) fn pre_snapshot_bytes(&self, path: &Path) -> anyhow::Result<Option<Vec<u8>>> {
if !path.exists() || !self.snapshot_eligible(path) {
return Ok(None);
}
Ok(Some(fs::read(path)?))
}
pub(super) fn record_file_snapshot(
&self,
tool: SnapshotTool,
path: &Path,
pre: Option<&[u8]>,
post: Option<&[u8]>,
) {
let Some(context) = self.checkpoint_context() else {
return;
};
if let Err(error) =
capture_file_snapshot(&context, &self.cwd_canonical, tool, path, pre, post)
{
eprintln!(
"warning: checkpoint snapshot failed: {}",
crate::output::redact_sensitive_text(&error.to_string())
);
}
}
}
#[derive(Debug, Clone)]
struct ReadText {
source: String,
kind: &'static str,
content: String,
metadata: Map<String, Value>,
truncated: bool,
}
#[derive(Debug, Clone)]
struct ReadResourceOutput {
content: String,
metadata: Map<String, Value>,
truncated: bool,
}
#[derive(Debug, Clone)]
struct FileLineSelection {
lines: Vec<(usize, String)>,
total_lines: Option<usize>,
truncated: bool,
}
#[derive(Debug, Clone)]
struct FormattedHashlineRead {
content: String,
seen_lines: Vec<usize>,
truncated: bool,
}
fn metadata_path(metadata: &Map<String, Value>) -> anyhow::Result<PathBuf> {
let value = metadata
.get(meta::PATH)
.ok_or_else(|| anyhow::anyhow!("file read metadata missing path"))?;
let path = serde_json::from_value::<PathBuf>(value.clone())?;
Ok(path)
}
fn select_file_lines(
normalized_text: &str,
offset: usize,
limit: usize,
selector: ReadSelector,
) -> FileLineSelection {
let all = split_lines_lossless(normalized_text);
let total = all.len();
let (start, end_limit) = match selector.lines {
Some(LineSelector::Single(line)) => (line.saturating_sub(1), line),
Some(LineSelector::Range { start, end }) => (start.saturating_sub(1), end),
Some(LineSelector::FromStart { end }) => (0, end),
Some(LineSelector::FromLine { start }) => (start.saturating_sub(1), total),
Some(LineSelector::Count { start, count }) => {
let zero = start.saturating_sub(1);
(zero, zero.saturating_add(count))
}
None => {
let start = offset.saturating_sub(1);
(start, start.saturating_add(limit))
}
};
let end = end_limit.min(total);
let lines = if start >= total || start >= end {
Vec::new()
} else {
all[start..end]
.iter()
.enumerate()
.map(|(index, line)| (start + index + 1, line.clone()))
.collect()
};
let truncated = end < total;
let total_lines = if selector.lines.is_some() || !truncated {
Some(total)
} else {
None
};
FileLineSelection {
lines,
total_lines,
truncated,
}
}
fn format_hashline_read(
path: &Path,
normalized_text: &str,
lines: &[(usize, String)],
) -> anyhow::Result<FormattedHashlineRead> {
let tag = compute_file_hash(normalized_text);
let path_text = path.to_string_lossy();
let header = format_hashline_header(&path_text, &tag);
let capacity = FILE_READ_MAX_BYTES as usize;
if header.len() > capacity {
anyhow::bail!("hashline read header exceeds {FILE_READ_MAX_BYTES} byte output limit");
}
let rows = format_numbered_lines(lines);
let mut content = header;
let mut seen_lines = Vec::new();
let mut truncated = false;
for ((line_number, _), row) in lines.iter().zip(rows) {
let addition_len = 1 + row.len();
if content.len() + addition_len > capacity {
truncated = true;
break;
}
content.push('\n');
content.push_str(&row);
seen_lines.push(*line_number);
}
Ok(FormattedHashlineRead {
content,
seen_lines,
truncated,
})
}
fn split_lines_lossless(text: &str) -> Vec<String> {
if text.is_empty() {
return Vec::new();
}
text.split_inclusive('\n')
.map(|line| line.strip_suffix('\n').unwrap_or(line).to_string())
.collect()
}
fn is_url_or_scheme(target: &str) -> bool {
target.starts_with("http://")
|| target.starts_with("https://")
|| target.starts_with("skill://")
|| target.starts_with("session://")
|| target.starts_with("issue://")
|| target.starts_with("pr://")
}
fn apply_legacy_slice(text: &str, offset: usize, limit: usize) -> (String, Option<usize>, bool) {
let start = offset.saturating_sub(1);
let end = start.saturating_add(limit);
let mut selected = Vec::new();
let mut total_lines = Some(0usize);
let mut truncated = false;
for (index, line) in text.lines().enumerate() {
if index >= end {
truncated = true;
total_lines = None;
break;
}
if index >= start {
selected.push(line.to_string());
}
total_lines = Some(index + 1);
}
(selected.join("\n"), total_lines, truncated)
}
fn append_multi_read_section(output: &mut String, requested_path: &str, body: &str) -> bool {
let separator = if output.is_empty() { "" } else { "\n\n" };
let section = format!("{separator}--- FILE: {requested_path} ---\n{body}");
if output.len() + section.len() <= FILE_READ_MAX_BYTES as usize {
output.push_str(§ion);
return true;
}
let notice = format!(
"{separator}--- FILE: {requested_path} ---\nERROR: aggregate read output limit is {FILE_READ_MAX_BYTES} bytes; this file and remaining files were omitted"
);
append_notice_with_cap(output, ¬ice);
false
}
fn append_notice_with_cap(output: &mut String, notice: &str) {
let capacity = FILE_READ_MAX_BYTES as usize;
let remaining = capacity.saturating_sub(output.len());
if remaining == 0 {
return;
}
if notice.len() <= remaining {
output.push_str(notice);
return;
}
let mut end = remaining;
while end > 0 && !notice.is_char_boundary(end) {
end -= 1;
}
output.push_str(¬ice[..end]);
}
fn nearest_existing_ancestor(path: &Path) -> anyhow::Result<PathBuf> {
let mut cursor = path;
loop {
if cursor.exists() {
return Ok(cursor.to_path_buf());
}
cursor = cursor
.parent()
.ok_or_else(|| anyhow::anyhow!("no existing ancestor for '{}'", path.display()))?;
}
}
fn lexical_normalize(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
checkpoints::{CheckpointStore, SnapshotContext},
config::McPaths,
tools::{
args::{ReadArgs, WriteArgs},
hash_edit::format::compute_file_hash,
},
};
use std::{path::Path, thread, time::Duration};
fn runtime_with_checkpoints(dir: &Path, mc_root: &Path) -> ToolRuntime {
let runtime = ToolRuntime::new(dir).expect("runtime");
let paths = McPaths::from_root(mc_root.to_path_buf());
runtime
.set_checkpoint_context(Some(SnapshotContext {
store: CheckpointStore::from_paths(&paths),
paths,
session_id: "session".to_string(),
user_turn: 1,
}))
.unwrap();
runtime
}
#[test]
fn read_file_emits_hashline_header_numbered_rows_and_snapshot_seen_lines() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("file.txt");
fs::write(&path, "one\ntwo \nthree\n").unwrap();
let path = path.canonicalize().unwrap();
let runtime = ToolRuntime::new(dir.path()).expect("runtime");
let result = runtime
.read(
ReadArgs {
path: Some("file.txt".to_string()),
paths: None,
offset: None,
limit: None,
},
&AgentCancellation::default(),
)
.unwrap();
let tag = compute_file_hash("one\ntwo \nthree\n");
assert_eq!(
result.content,
format!("[{}#{}]\n1:one\n2:two \n3:three", path.display(), tag)
);
let store = runtime.hashline_snapshots.lock().unwrap();
let snapshot = store.find_by_hash(&path, &tag).unwrap();
assert_eq!(snapshot.text, "one\ntwo \nthree\n");
assert_eq!(
snapshot.seen_lines,
std::collections::HashSet::from([1, 2, 3])
);
}
#[test]
fn read_file_partial_hashline_records_only_displayed_lines_for_full_file_tag() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("file.txt");
fs::write(&path, "one\ntwo\nthree\nfour\n").unwrap();
let path = path.canonicalize().unwrap();
let runtime = ToolRuntime::new(dir.path()).expect("runtime");
let result = runtime
.read(
ReadArgs {
path: Some("file.txt:2-3".to_string()),
paths: None,
offset: None,
limit: None,
},
&AgentCancellation::default(),
)
.unwrap();
let tag = compute_file_hash("one\ntwo\nthree\nfour\n");
assert_eq!(
result.content,
format!("[{}#{}]\n2:two\n3:three", path.display(), tag)
);
let store = runtime.hashline_snapshots.lock().unwrap();
let snapshot = store.find_by_hash(&path, &tag).unwrap();
assert_eq!(snapshot.text, "one\ntwo\nthree\nfour\n");
assert_eq!(snapshot.seen_lines, std::collections::HashSet::from([2, 3]));
}
#[test]
fn read_file_raw_selector_bypasses_hashline_and_snapshots() {
let dir = tempfile::tempdir().expect("tempdir");
fs::write(dir.path().join("file.txt"), "one\ntwo\n").unwrap();
let runtime = ToolRuntime::new(dir.path()).expect("runtime");
let result = runtime
.read(
ReadArgs {
path: Some("file.txt:raw".to_string()),
paths: None,
offset: None,
limit: None,
},
&AgentCancellation::default(),
)
.unwrap();
assert_eq!(result.content, "one\ntwo\n");
assert_eq!(runtime.hashline_snapshots.lock().unwrap().len(), 0);
}
#[test]
fn write_file_records_snapshot_for_existing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let mc = tempfile::tempdir().expect("mc");
fs::write(dir.path().join("file.txt"), "old").unwrap();
let runtime = runtime_with_checkpoints(dir.path(), mc.path());
runtime
.write_file(
WriteArgs {
path: "file.txt".to_string(),
content: "new".to_string(),
},
&AgentCancellation::default(),
)
.unwrap();
let store = runtime.checkpoint_context().unwrap().store;
let records = store.read_records("session").records;
assert_eq!(records.len(), 1);
assert!(records[0].event.pre.is_some());
assert!(records[0].event.post.is_some());
}
#[test]
fn write_file_records_created_file_snapshot() {
let dir = tempfile::tempdir().expect("tempdir");
let mc = tempfile::tempdir().expect("mc");
let runtime = runtime_with_checkpoints(dir.path(), mc.path());
runtime
.write_file(
WriteArgs {
path: "file.txt".to_string(),
content: "new".to_string(),
},
&AgentCancellation::default(),
)
.unwrap();
let records = runtime
.checkpoint_context()
.unwrap()
.store
.read_records("session")
.records;
assert_eq!(records.len(), 1);
assert!(records[0].event.pre.is_none());
assert!(records[0].event.post.is_some());
}
#[test]
fn write_file_does_not_snapshot_auth_json() {
let dir = tempfile::tempdir().expect("tempdir");
let mc = tempfile::tempdir().expect("mc");
let runtime = runtime_with_checkpoints(dir.path(), dir.path());
runtime
.write_file(
WriteArgs {
path: "auth.json".to_string(),
content: "topsecret-marker".to_string(),
},
&AgentCancellation::default(),
)
.unwrap();
let records = runtime
.checkpoint_context()
.unwrap()
.store
.read_records("session")
.records;
assert_eq!(records.len(), 1);
assert!(records[0].event.pre.is_none());
assert!(records[0].event.post.is_none());
assert!(
!fs::read_to_string(
runtime
.checkpoint_context()
.unwrap()
.paths
.checkpoints
.join("ledgers/session.jsonl")
)
.unwrap()
.contains("topsecret-marker")
);
drop(mc);
}
#[test]
fn write_waits_for_cross_process_file_lock() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("file.txt");
let held_lock = CrossProcessFileLock::acquire(&path).expect("hold lock");
let runtime = ToolRuntime::new(dir.path()).expect("runtime");
let writer = thread::spawn(move || {
runtime.write_file(
WriteArgs {
path: "file.txt".to_string(),
content: "new".to_string(),
},
&AgentCancellation::default(),
)
});
thread::sleep(Duration::from_millis(100));
assert!(
!path.exists(),
"write ran while cross-process lock was held"
);
drop(held_lock);
writer
.join()
.expect("writer thread")
.expect("write succeeds");
assert_eq!(fs::read_to_string(&path).expect("read written file"), "new");
}
}