Skip to main content

everruns_core/capabilities/
file_system.rs

1//! Session File System Capability
2//!
3//! This capability provides tools for interacting with the session file system.
4//! Each session has its own isolated filesystem stored in the database.
5//!
6//! Tools provided:
7//! - `read_file`: Read file content
8//! - `write_file`: Create or update a file
9//! - `edit_file`: Apply surgical text replacements to an existing file
10//! - `list_directory`: List files in a directory
11//! - `grep_files`: Search files by regex pattern
12//! - `delete_file`: Delete a file or directory
13//! - `stat_file`: Get file metadata
14
15use super::{
16    Capability, CapabilityLocalization, CapabilityStatus, SystemPromptContext, ToolDefinitionHook,
17};
18use crate::error::{FileSystemErrorClass, classify_fs_error};
19use crate::session_file::SessionFile;
20use crate::tool_output_sanitizer::build_binary_read_file_result;
21use crate::tool_types::{ToolDefinition, ToolHints};
22use crate::tools::{Tool, ToolExecutionResult, ToolResultImage};
23use crate::traits::{SessionFileSystem, ToolContext};
24use crate::truncation_info::{TruncationInfo, TruncationReason};
25use async_trait::async_trait;
26use serde_json::{Value, json};
27use sha2::{Digest, Sha256};
28use similar::TextDiff;
29use std::sync::Arc;
30
31/// Image MIME types recognized by LLM vision APIs (OpenAI, Anthropic)
32const IMAGE_EXTENSIONS: &[(&str, &str)] = &[
33    (".png", "image/png"),
34    (".jpg", "image/jpeg"),
35    (".jpeg", "image/jpeg"),
36    (".gif", "image/gif"),
37    (".webp", "image/webp"),
38];
39
40/// Get the image MIME type if the path has a known image extension
41fn image_media_type(path: &str) -> Option<&'static str> {
42    let lower = path.to_lowercase();
43    IMAGE_EXTENSIONS
44        .iter()
45        .find(|(ext, _)| lower.ends_with(ext))
46        .map(|(_, mime)| *mime)
47}
48
49/// Workspace prefix used in file paths
50const WORKSPACE_PREFIX: &str = "/workspace";
51const SESSION_FILE_SYSTEM_TOOL_NAMES: &[&str] = &[
52    "read_file",
53    "write_file",
54    "edit_file",
55    "list_directory",
56    "grep_files",
57    "delete_file",
58    "stat_file",
59];
60const MAX_EDIT_DIFF_CHARS: usize = 16_000;
61const LIST_DIRECTORY_DEFAULT_LIMIT: usize = 200;
62const LIST_DIRECTORY_MAX_LIMIT: usize = 1_000;
63const GREP_FILES_DEFAULT_LIMIT: usize = 200;
64const GREP_FILES_MAX_LIMIT: usize = 1_000;
65
66fn escape_xml_text(content: &str) -> String {
67    content
68        .replace('&', "&amp;")
69        .replace('<', "&lt;")
70        .replace('>', "&gt;")
71}
72
73/// Model-visible path identity derived from the active primary `SessionFileSystem`.
74#[derive(Debug, Clone, PartialEq, Eq)]
75struct FilePathPresentation {
76    root: String,
77}
78
79impl FilePathPresentation {
80    fn vfs() -> Self {
81        Self {
82            root: WORKSPACE_PREFIX.to_string(),
83        }
84    }
85
86    fn from_context(ctx: &SystemPromptContext) -> Self {
87        Self::from_file_store(
88            ctx.file_store
89                .as_ref()
90                .map(|store| store.as_ref() as &dyn SessionFileSystem),
91        )
92    }
93
94    fn from_file_store(store: Option<&dyn SessionFileSystem>) -> Self {
95        let root = store
96            .map(SessionFileSystem::display_root)
97            .unwrap_or_else(|| WORKSPACE_PREFIX.to_string());
98        Self { root }
99    }
100
101    fn uses_vfs_namespace(&self) -> bool {
102        self.root == WORKSPACE_PREFIX
103    }
104
105    fn root_guidance(&self) -> String {
106        if self.uses_vfs_namespace() {
107            format!(
108                "Workspace root: `{WORKSPACE_PREFIX}`. All file paths must start with `{WORKSPACE_PREFIX}`. "
109            )
110        } else {
111            let escaped_root = escape_xml_text(&self.root);
112            format!(
113                "Workspace root: `{escaped_root}`. Paths may be relative to this root or absolute under it. "
114            )
115        }
116    }
117
118    fn system_prompt_preview(&self) -> String {
119        if self.uses_vfs_namespace() {
120            format!(
121                "Workspace root: `{WORKSPACE_PREFIX}`. All file paths must start with `{WORKSPACE_PREFIX}`."
122            )
123        } else {
124            format!(
125                "Workspace root: `{}`. Paths may be relative to this root or absolute under it.",
126                self.root
127            )
128        }
129    }
130
131    fn path_param_description(&self, example: &str) -> String {
132        if self.uses_vfs_namespace() {
133            format!(
134                "Workspace-relative path (e.g., '{example}'). A leading '/' or '{WORKSPACE_PREFIX}/' prefix is also accepted."
135            )
136        } else {
137            format!(
138                "Path relative to `{}` (e.g., '{example}') or an absolute path under `{}`.",
139                self.root, self.root
140            )
141        }
142    }
143
144    fn generic_path_param_description(&self) -> String {
145        if self.uses_vfs_namespace() {
146            "Path to the file or directory. A leading '/' or '/workspace/' prefix is also accepted."
147                .to_string()
148        } else {
149            format!(
150                "Path relative to `{}` or an absolute path under `{}`.",
151                self.root, self.root
152            )
153        }
154    }
155
156    fn list_directory_path_description(&self) -> String {
157        if self.uses_vfs_namespace() {
158            format!(
159                "Workspace-relative directory path to list (e.g., 'src'). Defaults to the workspace root; a leading '/' or '{WORKSPACE_PREFIX}/' prefix is also accepted."
160            )
161        } else {
162            format!(
163                "Directory path relative to `{}` (e.g., 'src'). Defaults to `{}` when omitted.",
164                self.root, self.root
165            )
166        }
167    }
168
169    fn parameters_schema_for_tool(&self, tool_name: &str) -> Option<Value> {
170        match tool_name {
171            "read_file" => Some(read_file_parameters_schema(self)),
172            "write_file" => Some(write_file_parameters_schema(self)),
173            "edit_file" => Some(edit_file_parameters_schema(self)),
174            "list_directory" => Some(list_directory_parameters_schema(self)),
175            "grep_files" => Some(grep_files_parameters_schema()),
176            "delete_file" => Some(delete_file_parameters_schema(self)),
177            "stat_file" => Some(stat_file_parameters_schema(self)),
178            _ => None,
179        }
180    }
181}
182
183struct FilePathPresentationHook {
184    presentation: FilePathPresentation,
185}
186
187impl ToolDefinitionHook for FilePathPresentationHook {
188    fn transform(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
189        tools
190            .into_iter()
191            .map(|tool| {
192                if !SESSION_FILE_SYSTEM_TOOL_NAMES.contains(&tool.name()) {
193                    return tool;
194                }
195                let Some(schema) = self.presentation.parameters_schema_for_tool(tool.name()) else {
196                    return tool;
197                };
198                match tool {
199                    ToolDefinition::Builtin(mut builtin) => {
200                        builtin.parameters = schema.clone();
201                        if let Some(full) = builtin.full_parameters.as_mut() {
202                            *full = schema;
203                        }
204                        ToolDefinition::Builtin(builtin)
205                    }
206                    ToolDefinition::ClientSide(mut client) => {
207                        client.parameters = schema.clone();
208                        if let Some(full) = client.full_parameters.as_mut() {
209                            *full = schema;
210                        }
211                        ToolDefinition::ClientSide(client)
212                    }
213                }
214            })
215            .collect()
216    }
217}
218
219fn read_file_parameters_schema(presentation: &FilePathPresentation) -> Value {
220    json!({
221        "type": "object",
222        "properties": {
223            "path": {
224                "type": "string",
225                "description": presentation.path_param_description("docs/readme.txt")
226            },
227            "offset": {
228                "type": "integer",
229                "description": "Starting line number (0-indexed). Default: 0",
230                "default": 0,
231                "minimum": 0
232            },
233            "limit": {
234                "type": "integer",
235                "description": "Max lines to return. Default varies by file type: 2000 (source/text), 500 (logs, tail-biased), 100 (CSV/TSV with header). Explicit value always wins.",
236                "default": 2000,
237                "minimum": 1
238            }
239        },
240        "required": ["path"],
241        "additionalProperties": false
242    })
243}
244
245fn write_file_parameters_schema(presentation: &FilePathPresentation) -> Value {
246    json!({
247        "type": "object",
248        "properties": {
249            "path": {
250                "type": "string",
251                "description": presentation.path_param_description("docs/notes.txt")
252            },
253            "content": {
254                "type": "string",
255                "description": "Content to write to the file"
256            },
257            "encoding": {
258                "type": "string",
259                "enum": ["text", "base64"],
260                "default": "text",
261                "description": "Content encoding: 'text' for plain text, 'base64' for binary data"
262            }
263        },
264        "required": ["path", "content"],
265        "additionalProperties": false
266    })
267}
268
269fn edit_file_parameters_schema(presentation: &FilePathPresentation) -> Value {
270    json!({
271        "type": "object",
272        "properties": {
273            "path": {
274                "type": "string",
275                "description": presentation.path_param_description("src/main.rs")
276            },
277            "expected_hash": {
278                "type": "string",
279                "description": "Current content hash from read_file or write_file (format: 'sha256:...')"
280            },
281            "edits": {
282                "type": "array",
283                "description": "One or more replacements to apply, each matched against the original file content. Use a single-element array for one replacement.",
284                "items": {
285                    "type": "object",
286                    "properties": {
287                        "old_text": {
288                            "type": "string",
289                            "description": "Exact text to replace"
290                        },
291                        "new_text": {
292                            "type": "string",
293                            "description": "Replacement text"
294                        }
295                    },
296                    "required": ["old_text", "new_text"],
297                    "additionalProperties": false
298                },
299                "minItems": 1
300            }
301        },
302        "required": ["path", "expected_hash", "edits"],
303        "additionalProperties": false
304    })
305}
306
307fn list_directory_parameters_schema(presentation: &FilePathPresentation) -> Value {
308    json!({
309        "type": "object",
310        "properties": {
311            "path": {
312                "type": "string",
313                "default": presentation.root,
314                "description": presentation.list_directory_path_description()
315            },
316            "offset": {
317                "type": "integer",
318                "description": "Starting item offset for large directories. Default: 0",
319                "default": 0,
320                "minimum": 0
321            },
322            "limit": {
323                "type": "integer",
324                "description": "Max directory entries to return. Default: 200, maximum: 1000",
325                "default": LIST_DIRECTORY_DEFAULT_LIMIT,
326                "minimum": 1,
327                "maximum": LIST_DIRECTORY_MAX_LIMIT
328            }
329        },
330        "additionalProperties": false
331    })
332}
333
334fn grep_files_parameters_schema() -> Value {
335    json!({
336        "type": "object",
337        "properties": {
338            "pattern": {
339                "type": "string",
340                "description": "Regex pattern to search for"
341            },
342            "path_pattern": {
343                "type": "string",
344                "description": "Optional glob filtering canonical paths (e.g., '*.txt', 'docs/*', 'src/**/*.rs'). Basename-only globs match at any depth; non-glob values use legacy substring matching"
345            },
346            "before_context": {
347                "type": "integer",
348                "description": "Number of lines before each match. Default: 0, maximum: 20. Overlapping ranges are merged",
349                "default": 0,
350                "minimum": 0,
351                "maximum": crate::GREP_MAX_CONTEXT_LINES
352            },
353            "after_context": {
354                "type": "integer",
355                "description": "Number of lines after each match. Default: 0, maximum: 20. Overlapping ranges are merged",
356                "default": 0,
357                "minimum": 0,
358                "maximum": crate::GREP_MAX_CONTEXT_LINES
359            },
360            "offset": {
361                "type": "integer",
362                "description": "Starting match offset. Default: 0",
363                "default": 0,
364                "minimum": 0
365            },
366            "limit": {
367                "type": "integer",
368                "description": "Max matches to return. Default: 200, maximum: 1000",
369                "default": GREP_FILES_DEFAULT_LIMIT,
370                "minimum": 1,
371                "maximum": GREP_FILES_MAX_LIMIT
372            }
373        },
374        "required": ["pattern"],
375        "additionalProperties": false
376    })
377}
378
379fn delete_file_parameters_schema(presentation: &FilePathPresentation) -> Value {
380    json!({
381        "type": "object",
382        "properties": {
383            "path": {
384                "type": "string",
385                "description": presentation.generic_path_param_description()
386            },
387            "recursive": {
388                "type": "boolean",
389                "default": false,
390                "description": "If true, delete directories and all contents recursively"
391            }
392        },
393        "required": ["path"],
394        "additionalProperties": false
395    })
396}
397
398fn stat_file_parameters_schema(presentation: &FilePathPresentation) -> Value {
399    json!({
400        "type": "object",
401        "properties": {
402            "path": {
403                "type": "string",
404                "description": presentation.generic_path_param_description()
405            }
406        },
407        "required": ["path"],
408        "additionalProperties": false
409    })
410}
411
412#[cfg(test)]
413fn schema_contains_workspace(value: &Value) -> bool {
414    fn walk(value: &Value) -> bool {
415        match value {
416            Value::String(text) => text.contains(WORKSPACE_PREFIX),
417            Value::Array(items) => items.iter().any(walk),
418            Value::Object(fields) => fields.values().any(walk),
419            _ => false,
420        }
421    }
422    walk(value)
423}
424
425#[cfg(test)]
426fn filesystem_tool_schemas_with_presentation(
427    presentation: &FilePathPresentation,
428) -> Vec<(String, Value)> {
429    SESSION_FILE_SYSTEM_TOOL_NAMES
430        .iter()
431        .filter_map(|name| {
432            presentation
433                .parameters_schema_for_tool(name)
434                .map(|schema| ((*name).to_string(), schema))
435        })
436        .collect()
437}
438
439// ============================================================================
440// Content-type detection (EVE-249)
441// ============================================================================
442
443/// Content type categories for read_file default behavior.
444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
445enum ContentType {
446    /// Source code, markdown, config — standard 2000-line default
447    Text,
448    /// Log files — tail-biased (last 500 lines)
449    Log,
450    /// CSV/TSV data — 100-line default with header prepend
451    Csv,
452    /// Known binary formats — metadata only (no inline content)
453    Binary,
454    /// Minified files — first 500 chars only
455    Minified,
456}
457
458/// Read mode for content-type-aware defaults.
459#[derive(Debug, Clone, Copy, PartialEq, Eq)]
460enum ReadMode {
461    /// Read from the beginning (standard)
462    FromOffset,
463    /// Read from the end (tail-biased for logs)
464    FromEnd,
465    /// Return metadata only, no content
466    MetadataOnly,
467}
468
469/// Detect content type from file extension.
470fn content_type_from_extension(path: &str) -> ContentType {
471    let lower = path.to_lowercase();
472
473    // Check minified first (.min.js, .min.css) before generic .js/.css
474    if lower.ends_with(".min.js") || lower.ends_with(".min.css") {
475        return ContentType::Minified;
476    }
477
478    // Log files
479    if lower.ends_with(".log") || lower.ends_with(".out") {
480        return ContentType::Log;
481    }
482
483    // CSV/TSV data files
484    if lower.ends_with(".csv") || lower.ends_with(".tsv") {
485        return ContentType::Csv;
486    }
487
488    // Binary formats (images already handled separately via image_media_type)
489    const BINARY_EXTENSIONS: &[&str] = &[
490        ".wasm", ".zip", ".tar", ".gz", ".bz2", ".xz", ".zst", ".7z", ".rar", ".exe", ".dll",
491        ".so", ".dylib", ".bin", ".dat", ".o", ".a", ".pyc", ".class", ".woff", ".woff2", ".ttf",
492        ".otf", ".eot", ".ico", ".bmp", ".tiff", ".tif", ".psd", ".mp3", ".mp4", ".avi", ".mov",
493        ".flv", ".wmv", ".pdf",
494    ];
495    if BINARY_EXTENSIONS.iter().any(|ext| lower.ends_with(ext)) {
496        return ContentType::Binary;
497    }
498
499    ContentType::Text
500}
501
502/// Resolve effective limit and read mode based on content type.
503/// Returns (limit, read_mode). Explicit user values always win.
504fn effective_read_defaults(
505    path: &str,
506    explicit_offset: bool,
507    explicit_limit: bool,
508) -> (usize, ReadMode) {
509    if explicit_limit && explicit_offset {
510        // User provided both — don't override anything
511        return (0, ReadMode::FromOffset); // limit is already set by caller
512    }
513    match content_type_from_extension(path) {
514        ContentType::Log if !explicit_offset => (500, ReadMode::FromEnd),
515        ContentType::Log => (500, ReadMode::FromOffset),
516        ContentType::Csv => (100, ReadMode::FromOffset),
517        ContentType::Binary => (0, ReadMode::MetadataOnly),
518        ContentType::Minified => (20, ReadMode::FromOffset), // ~20 lines, capped by byte limit
519        ContentType::Text => (
520            crate::tool_output_sanitizer::READ_FILE_DEFAULT_LIMIT,
521            ReadMode::FromOffset,
522        ),
523    }
524}
525
526fn fs_display_path(file_store: &dyn SessionFileSystem, path: &str) -> String {
527    file_store.display_path(path)
528}
529
530fn fs_input_display_path(file_store: &dyn SessionFileSystem, path: &str) -> String {
531    if file_store.is_mount_resolver() {
532        file_store.resolve_path(path)
533    } else {
534        file_store.display_path(path)
535    }
536}
537
538fn file_content_hash(content: &str, encoding: &str) -> crate::error::Result<String> {
539    let bytes = SessionFile::decode_content(content, encoding)
540        .map_err(|error| anyhow::anyhow!("failed to decode file content for hashing: {error}"))?;
541    Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes))))
542}
543
544fn session_file_content_hash(file: &SessionFile) -> crate::error::Result<String> {
545    file_content_hash(file.content.as_deref().unwrap_or_default(), &file.encoding)
546}
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
549enum LineEnding {
550    Lf,
551    Cr,
552    Crlf,
553}
554
555fn strip_utf8_bom(content: &str) -> (bool, &str) {
556    if let Some(stripped) = content.strip_prefix('\u{feff}') {
557        (true, stripped)
558    } else {
559        (false, content)
560    }
561}
562
563fn detect_line_ending(content: &str) -> LineEnding {
564    if content.contains("\r\n") {
565        LineEnding::Crlf
566    } else if content.contains('\r') {
567        LineEnding::Cr
568    } else {
569        LineEnding::Lf
570    }
571}
572
573fn align_to_file_line_endings(content: &str, line_ending: LineEnding) -> String {
574    let normalized = content.replace("\r\n", "\n").replace('\r', "\n");
575    match line_ending {
576        LineEnding::Lf => normalized,
577        LineEnding::Cr => normalized.replace('\n', "\r"),
578        LineEnding::Crlf => normalized.replace('\n', "\r\n"),
579    }
580}
581
582fn normalize_line_endings(content: &str) -> String {
583    content.replace("\r\n", "\n").replace('\r', "\n")
584}
585
586fn truncate_snippet(content: &str, max_chars: usize) -> String {
587    let clean = content.replace('\n', "\\n").replace('\r', "\\r");
588    if clean.chars().count() <= max_chars {
589        clean
590    } else {
591        let truncated: String = clean.chars().take(max_chars).collect();
592        format!("{truncated}...")
593    }
594}
595
596fn first_changed_line(before: &str, after: &str) -> Option<usize> {
597    if before == after {
598        return None;
599    }
600
601    let before = normalize_line_endings(before);
602    let after = normalize_line_endings(after);
603    let before_lines: Vec<&str> = before.split('\n').collect();
604    let after_lines: Vec<&str> = after.split('\n').collect();
605
606    for index in 0..before_lines.len().max(after_lines.len()) {
607        if before_lines.get(index) != after_lines.get(index) {
608            return Some(index + 1);
609        }
610    }
611
612    Some(1)
613}
614
615fn render_unified_diff(path: &str, before: &str, after: &str) -> String {
616    TextDiff::from_lines(
617        normalize_line_endings(before).as_str(),
618        normalize_line_endings(after).as_str(),
619    )
620    .unified_diff()
621    .context_radius(2)
622    .header(&format!("{path} (before)"), &format!("{path} (after)"))
623    .to_string()
624}
625
626fn truncate_diff(diff: String) -> (String, bool) {
627    if diff.chars().count() <= MAX_EDIT_DIFF_CHARS {
628        return (diff, false);
629    }
630
631    let truncated: String = diff.chars().take(MAX_EDIT_DIFF_CHARS).collect();
632    (
633        format!("{truncated}\n... diff truncated after {MAX_EDIT_DIFF_CHARS} characters ..."),
634        true,
635    )
636}
637
638#[derive(Debug, Clone, PartialEq, Eq)]
639struct TextEdit {
640    old_text: String,
641    new_text: String,
642}
643
644#[derive(Debug, Clone, PartialEq, Eq)]
645struct PlannedEdit {
646    start: usize,
647    end: usize,
648    replacement: String,
649}
650
651/// Coerce a legacy top-level `old_text`/`new_text` pair into a single edit.
652///
653/// The advertised `edit_file` schema is `edits[]`-only (EVE-620), but stored
654/// calls and stubborn structured-tool-call models may still emit the scalar
655/// fields. Rather than rejecting (the EVE-616 corrective error is now a last
656/// resort), we fold them into `edits[]` — the same `prepareArguments` approach
657/// pi uses. Empty-string placeholders (which some models emit alongside a real
658/// `edits[]`) are treated as absent, not as an error.
659fn coerce_top_level_edit(arguments: &Value) -> std::result::Result<Option<TextEdit>, String> {
660    let old_text_arg = arguments.get("old_text");
661    let new_text_arg = arguments.get("new_text");
662    if old_text_arg.is_none() && new_text_arg.is_none() {
663        return Ok(None);
664    }
665
666    let old_text = old_text_arg
667        .and_then(Value::as_str)
668        .ok_or_else(|| "Legacy top-level old_text must be a string".to_string())?;
669    let new_text = new_text_arg
670        .and_then(Value::as_str)
671        .ok_or_else(|| "Legacy top-level new_text must be a string".to_string())?;
672
673    // Empty legacy placeholders carry no replacement target.
674    if old_text.is_empty() {
675        if !new_text.is_empty() {
676            return Err("Legacy top-level old_text cannot be empty".to_string());
677        }
678        return Ok(None);
679    }
680
681    Ok(Some(TextEdit {
682        old_text: old_text.to_string(),
683        new_text: new_text.to_string(),
684    }))
685}
686
687fn parse_text_edits(arguments: &Value) -> std::result::Result<Vec<TextEdit>, String> {
688    let mut edits: Vec<TextEdit> = Vec::new();
689
690    // Backward-compat: fold a legacy top-level old_text/new_text pair into
691    // edits[] instead of rejecting (EVE-620).
692    if let Some(top_level) = coerce_top_level_edit(arguments)? {
693        edits.push(top_level);
694    }
695
696    if let Some(array) = arguments.get("edits").and_then(Value::as_array) {
697        for (index, edit) in array.iter().enumerate() {
698            let old_text = edit
699                .get("old_text")
700                .and_then(Value::as_str)
701                .ok_or_else(|| format!("Edit {} is missing old_text", index + 1))?;
702            let new_text = edit
703                .get("new_text")
704                .and_then(Value::as_str)
705                .ok_or_else(|| format!("Edit {} is missing new_text", index + 1))?;
706            if old_text.is_empty() {
707                return Err(format!("Edit {} has an empty old_text", index + 1));
708            }
709            let edit = TextEdit {
710                old_text: old_text.to_string(),
711                new_text: new_text.to_string(),
712            };
713            // Dedup the coerced top-level edit when a model duplicates it into
714            // edits[] (the gpt-5.5 mixed-mode pattern): two identical edits would
715            // otherwise match the same span and trip the overlap check.
716            if !edits.contains(&edit) {
717                edits.push(edit);
718            }
719        }
720    }
721
722    if edits.is_empty() {
723        return Err(
724            "edit_file requires a non-empty edits[] array; each entry needs old_text and new_text"
725                .to_string(),
726        );
727    }
728
729    Ok(edits)
730}
731
732fn plan_text_edits(
733    content: &str,
734    edits: &[TextEdit],
735) -> std::result::Result<Vec<PlannedEdit>, String> {
736    let (_, body) = strip_utf8_bom(content);
737    let line_ending = detect_line_ending(body);
738    let mut planned = Vec::with_capacity(edits.len());
739
740    for edit in edits {
741        let old_text = align_to_file_line_endings(
742            edit.old_text
743                .strip_prefix('\u{feff}')
744                .unwrap_or(&edit.old_text),
745            line_ending,
746        );
747        let new_text = align_to_file_line_endings(
748            edit.new_text
749                .strip_prefix('\u{feff}')
750                .unwrap_or(&edit.new_text),
751            line_ending,
752        );
753
754        let mut matches = body.match_indices(&old_text);
755        let Some((start, _)) = matches.next() else {
756            return Err(format!(
757                "Could not find an exact match for old_text: '{}'",
758                truncate_snippet(&old_text, 80)
759            ));
760        };
761        if matches.next().is_some() {
762            return Err(format!(
763                "old_text is ambiguous and matched multiple locations: '{}'",
764                truncate_snippet(&old_text, 80)
765            ));
766        }
767
768        planned.push(PlannedEdit {
769            start,
770            end: start + old_text.len(),
771            replacement: new_text,
772        });
773    }
774
775    planned.sort_by_key(|edit| edit.start);
776    for pair in planned.windows(2) {
777        if pair[1].start < pair[0].end {
778            return Err("Edits overlap in the target file".to_string());
779        }
780    }
781
782    Ok(planned)
783}
784
785fn apply_text_edits(
786    content: &str,
787    edits: &[TextEdit],
788) -> std::result::Result<(String, usize), String> {
789    let (had_bom, body) = strip_utf8_bom(content);
790    let planned = plan_text_edits(content, edits)?;
791
792    let mut edited = String::with_capacity(content.len());
793    let mut cursor = 0;
794    for edit in &planned {
795        edited.push_str(&body[cursor..edit.start]);
796        edited.push_str(&edit.replacement);
797        cursor = edit.end;
798    }
799    edited.push_str(&body[cursor..]);
800
801    if had_bom {
802        edited.insert(0, '\u{feff}');
803    }
804
805    Ok((edited, planned.len()))
806}
807
808pub const SESSION_FILE_SYSTEM_CAPABILITY_ID: &str = "session_file_system";
809
810/// Session File System capability - provides file operations for session storage
811pub struct FileSystemCapability;
812
813#[async_trait]
814impl Capability for FileSystemCapability {
815    fn id(&self) -> &str {
816        SESSION_FILE_SYSTEM_CAPABILITY_ID
817    }
818
819    fn name(&self) -> &str {
820        "File System"
821    }
822
823    fn description(&self) -> &str {
824        r#"Tools to access and manipulate files in the session workspace - read, write, list, grep, and more.
825
826> [!NOTE]
827> Each session has its own isolated workspace. Files persist for the session duration.
828
829> [!TIP]
830> Use `list_directory` to explore the workspace structure before reading or writing files."#
831    }
832
833    fn localizations(&self) -> Vec<CapabilityLocalization> {
834        vec![CapabilityLocalization::text(
835            "uk",
836            "Файлова система",
837            r#"Інструменти для доступу до файлів у робочому просторі сесії та роботи з ними — читання, запис, перегляд, пошук grep тощо.
838
839> [!NOTE]
840> Кожна сесія має власний ізольований робочий простір. Файли зберігаються протягом усієї сесії.
841
842> [!TIP]
843> Використовуйте `list_directory`, щоб дослідити структуру робочого простору перед читанням або записом файлів."#,
844        )]
845    }
846
847    fn status(&self) -> CapabilityStatus {
848        CapabilityStatus::Available
849    }
850
851    fn icon(&self) -> Option<&str> {
852        Some("hard-drive")
853    }
854
855    fn category(&self) -> Option<&str> {
856        Some("File Operations")
857    }
858
859    async fn system_prompt_contribution(&self, ctx: &SystemPromptContext) -> Option<String> {
860        use crate::tool_output_sanitizer::READ_ECONOMY_HINT;
861        let presentation = FilePathPresentation::from_context(ctx);
862        Some(format!(
863            "<capability id=\"{}\">\n{}Directories are created on write. Read files before claiming what they contain — never speculate about code you have not opened.{}\n</capability>",
864            self.id(),
865            presentation.root_guidance(),
866            READ_ECONOMY_HINT
867        ))
868    }
869
870    fn system_prompt_preview(&self) -> Option<String> {
871        Some(FilePathPresentation::vfs().system_prompt_preview())
872    }
873
874    fn tool_definition_hooks_with_context(
875        &self,
876        ctx: &SystemPromptContext,
877        _config: &serde_json::Value,
878    ) -> Vec<Arc<dyn ToolDefinitionHook>> {
879        vec![Arc::new(FilePathPresentationHook {
880            presentation: FilePathPresentation::from_context(ctx),
881        })]
882    }
883
884    fn tools(&self) -> Vec<Box<dyn Tool>> {
885        vec![
886            Box::new(ReadFileTool),
887            Box::new(WriteFileTool),
888            Box::new(EditFileTool),
889            Box::new(ListDirectoryTool),
890            Box::new(GrepFilesTool),
891            Box::new(DeleteFileTool),
892            Box::new(StatFileTool),
893        ]
894    }
895
896    fn features(&self) -> Vec<&'static str> {
897        vec!["file_system"]
898    }
899}
900
901// ============================================================================
902// ReadFileTool
903// ============================================================================
904
905/// Tool to read file content
906pub struct ReadFileTool;
907
908#[async_trait]
909impl Tool for ReadFileTool {
910    fn narrate(
911        &self,
912        tool_call: &crate::tool_types::ToolCall,
913        phase: crate::tool_narration::ToolNarrationPhase,
914        locale: Option<&str>,
915        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
916    ) -> Option<String> {
917        Some(crate::tool_narration::narrate_read_file(
918            &tool_call.arguments,
919            phase,
920            locale,
921        ))
922    }
923
924    fn name(&self) -> &str {
925        "read_file"
926    }
927
928    fn display_name(&self) -> Option<&str> {
929        Some("Read File")
930    }
931
932    fn description(&self) -> &str {
933        "Read a file from the session workspace. Returns text content directly. For image files (PNG, JPEG, GIF, WebP), the image is returned as a native image so you can see it visually. This is NOT for reading files in cloud sandboxes — use the sandbox-specific read tool instead."
934    }
935
936    fn parameters_schema(&self) -> Value {
937        read_file_parameters_schema(&FilePathPresentation::vfs())
938    }
939
940    fn hints(&self) -> ToolHints {
941        ToolHints::default()
942            .with_readonly(true)
943            .with_idempotent(true)
944    }
945
946    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
947        ToolExecutionResult::tool_error(
948            "read_file requires context. This tool must be executed with session context.",
949        )
950    }
951
952    async fn execute_with_context(
953        &self,
954        arguments: Value,
955        context: &ToolContext,
956    ) -> ToolExecutionResult {
957        use crate::tool_output_sanitizer::{
958            READ_FILE_DEFAULT_LIMIT, apply_read_file_hard_cap, format_lines,
959        };
960
961        let path = match arguments.get("path").and_then(|v| v.as_str()) {
962            Some(p) => p,
963            None => return ToolExecutionResult::tool_error("Missing required parameter: path"),
964        };
965
966        let explicit_offset = arguments.get("offset").and_then(|v| v.as_u64()).is_some();
967        let explicit_limit = arguments.get("limit").and_then(|v| v.as_u64()).is_some();
968
969        let mut offset = arguments
970            .get("offset")
971            .and_then(|v| v.as_u64())
972            .unwrap_or(0) as usize;
973        let mut limit = arguments
974            .get("limit")
975            .and_then(|v| v.as_u64())
976            .unwrap_or(READ_FILE_DEFAULT_LIMIT as u64) as usize;
977
978        let file_store = match &context.file_store {
979            Some(store) => store,
980            None => {
981                return ToolExecutionResult::tool_error(
982                    "File system not available in this context",
983                );
984            }
985        };
986
987        // Normalize path to strip /workspace prefix for storage
988        // The store (MountFs in production) is the sole resolver: hand it the
989        // raw path and it routes `/workspace`, the root mount, and relatives.
990        let normalized_path = path.to_string();
991        let display_path = fs_input_display_path(file_store.as_ref(), &normalized_path);
992
993        match file_store
994            .read_file(context.session_id, &normalized_path)
995            .await
996        {
997            Ok(Some(file)) => {
998                let resolved_path = file.path.as_str();
999                let display_path = fs_display_path(file_store.as_ref(), resolved_path);
1000
1001                if file.is_directory {
1002                    return ToolExecutionResult::tool_error(format!(
1003                        "Path '{}' is a directory, not a file. Use list_directory instead.",
1004                        display_path
1005                    ));
1006                }
1007
1008                // Check if this is an image file that should be returned as native image content
1009                if let Some(media_type) = image_media_type(resolved_path) {
1010                    // For base64-encoded files, return as image
1011                    if file.encoding == "base64"
1012                        && let Some(ref content) = file.content
1013                    {
1014                        let content_hash = match file_content_hash(content, &file.encoding) {
1015                            Ok(hash) => hash,
1016                            Err(e) => return ToolExecutionResult::internal_error(e),
1017                        };
1018                        return ToolExecutionResult::success_with_images(
1019                            json!({
1020                                "path": display_path,
1021                                "media_type": media_type,
1022                                "size_bytes": file.size_bytes,
1023                                "content_hash": content_hash
1024                            }),
1025                            vec![ToolResultImage {
1026                                base64: content.clone(),
1027                                media_type: media_type.to_string(),
1028                            }],
1029                        );
1030                    }
1031                    // Text-encoded image paths still get returned as text (unusual case)
1032                }
1033
1034                let content_hash = match session_file_content_hash(&file) {
1035                    Ok(hash) => hash,
1036                    Err(e) => return ToolExecutionResult::internal_error(e),
1037                };
1038
1039                // Non-image binary files: return metadata only. Base64 payloads
1040                // are token-expensive and usually not useful to the model.
1041                if file.encoding == "base64" {
1042                    let mut result = build_binary_read_file_result(
1043                        &display_path,
1044                        file.size_bytes as usize,
1045                        "base64",
1046                    );
1047                    result["content_hash"] = json!(content_hash);
1048                    return ToolExecutionResult::success(result);
1049                }
1050
1051                let raw_content = file.content.as_deref().unwrap_or("");
1052
1053                // Apply content-type-aware defaults (EVE-249)
1054                let (ct_limit, read_mode) =
1055                    effective_read_defaults(resolved_path, explicit_offset, explicit_limit);
1056                let content_type = content_type_from_extension(resolved_path);
1057
1058                // Metadata-only for known binary extensions
1059                if read_mode == ReadMode::MetadataOnly {
1060                    let mut result = build_binary_read_file_result(
1061                        &display_path,
1062                        file.size_bytes as usize,
1063                        "binary",
1064                    );
1065                    result["content_hash"] = json!(content_hash);
1066                    return ToolExecutionResult::success(result);
1067                }
1068
1069                // Apply content-type defaults when user didn't specify
1070                if !explicit_limit {
1071                    limit = ct_limit;
1072                }
1073
1074                // Tail-biased reading for log files
1075                if read_mode == ReadMode::FromEnd && !explicit_offset {
1076                    let total = raw_content.lines().count();
1077                    offset = total.saturating_sub(limit);
1078                }
1079
1080                let (formatted, total_lines, truncated) = format_lines(raw_content, offset, limit);
1081
1082                // CSV: prepend header row when reading from an offset past line 0
1083                let formatted = if content_type == ContentType::Csv && offset > 0 {
1084                    if let Some(header) = raw_content.lines().next() {
1085                        format!("1|{header}\n{formatted}")
1086                    } else {
1087                        formatted
1088                    }
1089                } else {
1090                    formatted
1091                };
1092
1093                let shown_count = total_lines.saturating_sub(offset).min(limit);
1094                let (start_line, end_line) = if shown_count == 0 {
1095                    (0, 0)
1096                } else {
1097                    (offset + 1, offset + shown_count)
1098                };
1099
1100                // Generate structural outline for unread portions (EVE-248)
1101                let mut formatted = if truncated && start_line > 0 {
1102                    let outline_items =
1103                        crate::outline::generate_outline(raw_content, resolved_path);
1104                    if let Some(outline_text) = crate::outline::format_outline(
1105                        &outline_items,
1106                        start_line,
1107                        end_line,
1108                        total_lines,
1109                    ) {
1110                        format!("{formatted}{outline_text}")
1111                    } else {
1112                        formatted
1113                    }
1114                } else {
1115                    formatted
1116                };
1117                // Reapply hard cap after any post-format decorations (e.g. outlines).
1118                let hard_capped = apply_read_file_hard_cap(&mut formatted);
1119                let truncated = truncated || hard_capped;
1120
1121                let mut result = json!({
1122                    "path": display_path,
1123                    "content": formatted,
1124                    "total_lines": total_lines,
1125                    "lines_shown": {
1126                        "start": start_line,
1127                        "end": end_line
1128                    },
1129                    "truncated": truncated,
1130                    "size_bytes": file.size_bytes,
1131                    "content_hash": content_hash
1132                });
1133
1134                // Add content_type and read_mode metadata (EVE-249)
1135                if content_type != ContentType::Text {
1136                    let ct_label = match content_type {
1137                        ContentType::Log => "log",
1138                        ContentType::Csv => "csv",
1139                        ContentType::Minified => "minified",
1140                        _ => "text",
1141                    };
1142                    if let Some(obj) = result.as_object_mut() {
1143                        obj.insert("content_type".to_string(), json!(ct_label));
1144                        if read_mode == ReadMode::FromEnd {
1145                            obj.insert("read_mode".to_string(), json!("tail"));
1146                        }
1147                    }
1148                }
1149
1150                // Unified reading-tool truncation envelope (EVE-339).
1151                //
1152                // Distinguishing which cap fired:
1153                // - When `end_line < total_lines` the line window was clipped
1154                //   by `limit`, so this is a line cap and line-based resume is
1155                //   safe.
1156                // - When `truncated == true` but `end_line == total_lines` the
1157                //   line window covered every line and the cut must have come
1158                //   from the byte cap inside `format_lines`. Byte truncation
1159                //   can cut mid-line, so `next_offset = end_line` is not a
1160                //   reliable resume point — emit `without_resume` and let the
1161                //   caller narrow `limit` or shift `offset`.
1162                let truncation = if truncated {
1163                    if end_line < total_lines {
1164                        TruncationInfo::with_resume(
1165                            formatted.len(),
1166                            Some(file.size_bytes as usize),
1167                            end_line as u64,
1168                            format!(
1169                                "call read_file with offset={} to resume from line {}",
1170                                end_line,
1171                                end_line + 1,
1172                            ),
1173                            TruncationReason::LineCap,
1174                        )
1175                    } else {
1176                        TruncationInfo::without_resume(
1177                            formatted.len(),
1178                            Some(file.size_bytes as usize),
1179                            TruncationReason::SizeCap,
1180                        )
1181                    }
1182                } else {
1183                    TruncationInfo::not_truncated(formatted.len())
1184                };
1185                truncation.attach(&mut result);
1186
1187                ToolExecutionResult::success(result)
1188            }
1189            Ok(None) => {
1190                ToolExecutionResult::tool_error(format!("File not found: {}", display_path))
1191            }
1192            Err(e) => ToolExecutionResult::internal_error(e),
1193        }
1194    }
1195
1196    fn requires_context(&self) -> bool {
1197        true
1198    }
1199}
1200
1201// ============================================================================
1202// WriteFileTool
1203// ============================================================================
1204
1205/// Tool to write/create a file
1206pub struct WriteFileTool;
1207
1208#[async_trait]
1209impl Tool for WriteFileTool {
1210    fn narrate(
1211        &self,
1212        tool_call: &crate::tool_types::ToolCall,
1213        phase: crate::tool_narration::ToolNarrationPhase,
1214        locale: Option<&str>,
1215        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1216    ) -> Option<String> {
1217        Some(crate::tool_narration::narrate_write_file(
1218            &tool_call.arguments,
1219            phase,
1220            locale,
1221        ))
1222    }
1223
1224    fn name(&self) -> &str {
1225        "write_file"
1226    }
1227
1228    fn display_name(&self) -> Option<&str> {
1229        Some("Write File")
1230    }
1231
1232    fn description(&self) -> &str {
1233        "Create or update a file in the session workspace. Parent directories are created automatically. This is NOT for writing files in cloud sandboxes — use sandbox-specific write tools (e.g. daytona_write_file, e2b_write_file) instead."
1234    }
1235
1236    fn parameters_schema(&self) -> Value {
1237        write_file_parameters_schema(&FilePathPresentation::vfs())
1238    }
1239
1240    fn hints(&self) -> ToolHints {
1241        // Mutates the shared session workspace: serialize against other
1242        // workspace writes (and bash) within a batch to avoid races.
1243        ToolHints::default()
1244            .with_idempotent(true)
1245            .with_concurrency_class("session_workspace")
1246    }
1247
1248    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1249        ToolExecutionResult::tool_error(
1250            "write_file requires context. This tool must be executed with session context.",
1251        )
1252    }
1253
1254    async fn execute_with_context(
1255        &self,
1256        arguments: Value,
1257        context: &ToolContext,
1258    ) -> ToolExecutionResult {
1259        let path = match arguments.get("path").and_then(|v| v.as_str()) {
1260            Some(p) => p,
1261            None => return ToolExecutionResult::tool_error("Missing required parameter: path"),
1262        };
1263
1264        let content = match arguments.get("content").and_then(|v| v.as_str()) {
1265            Some(c) => c,
1266            None => return ToolExecutionResult::tool_error("Missing required parameter: content"),
1267        };
1268
1269        let encoding = arguments
1270            .get("encoding")
1271            .and_then(|v| v.as_str())
1272            .unwrap_or("text");
1273
1274        let file_store = match &context.file_store {
1275            Some(store) => store,
1276            None => {
1277                return ToolExecutionResult::tool_error(
1278                    "File system not available in this context",
1279                );
1280            }
1281        };
1282
1283        // Normalize path to strip /workspace prefix for storage
1284        // The store (MountFs in production) is the sole resolver: hand it the
1285        // raw path and it routes `/workspace`, the root mount, and relatives.
1286        let normalized_path = path.to_string();
1287        let display_path = fs_input_display_path(file_store.as_ref(), &normalized_path);
1288
1289        match file_store
1290            .write_file(context.session_id, &normalized_path, content, encoding)
1291            .await
1292        {
1293            Ok(file) => {
1294                let content_hash = match session_file_content_hash(&file) {
1295                    Ok(hash) => hash,
1296                    Err(e) => return ToolExecutionResult::internal_error(e),
1297                };
1298                ToolExecutionResult::success(json!({
1299                    "path": display_path,
1300                    "size_bytes": file.size_bytes,
1301                    "created": true,
1302                    "content_hash": content_hash
1303                }))
1304            }
1305            Err(e) => write_failure_result(e),
1306        }
1307    }
1308
1309    fn requires_context(&self) -> bool {
1310        true
1311    }
1312}
1313
1314/// Map a write/edit failure to a tool error (agent-correctable) or an internal
1315/// error. Read-only targets and directory-vs-file mismatches are the agent's to
1316/// fix; everything else is internal. EVE-645: routed through the typed
1317/// [`classify_fs_error`] seam instead of inline `msg.contains(...)`.
1318fn write_failure_result<E>(e: E) -> ToolExecutionResult
1319where
1320    E: std::error::Error + Send + Sync + 'static,
1321{
1322    match classify_fs_error(&e) {
1323        FileSystemErrorClass::ReadOnly | FileSystemErrorClass::IsADirectory => {
1324            ToolExecutionResult::tool_error(e.to_string())
1325        }
1326        _ => ToolExecutionResult::internal_error(e),
1327    }
1328}
1329
1330// ============================================================================
1331// EditFileTool
1332// ============================================================================
1333
1334/// Tool to apply exact text replacements to an existing text file
1335pub struct EditFileTool;
1336
1337#[async_trait]
1338impl Tool for EditFileTool {
1339    fn narrate(
1340        &self,
1341        tool_call: &crate::tool_types::ToolCall,
1342        phase: crate::tool_narration::ToolNarrationPhase,
1343        locale: Option<&str>,
1344        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1345    ) -> Option<String> {
1346        Some(crate::tool_narration::narrate_edit_file(
1347            &tool_call.arguments,
1348            phase,
1349            locale,
1350        ))
1351    }
1352
1353    fn name(&self) -> &str {
1354        "edit_file"
1355    }
1356
1357    fn display_name(&self) -> Option<&str> {
1358        Some("Edit File")
1359    }
1360
1361    fn description(&self) -> &str {
1362        "Apply one or more exact text replacements to an existing text file. Requires the current content hash from read_file or write_file. Provide every replacement as an entry in edits[] (use a single-element array for one replacement)."
1363    }
1364
1365    fn parameters_schema(&self) -> Value {
1366        edit_file_parameters_schema(&FilePathPresentation::vfs())
1367    }
1368
1369    fn hints(&self) -> ToolHints {
1370        // Mutates the shared session workspace: serialize against other
1371        // workspace writes (and bash) within a batch to avoid races.
1372        ToolHints::default().with_concurrency_class("session_workspace")
1373    }
1374
1375    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1376        ToolExecutionResult::tool_error(
1377            "edit_file requires context. This tool must be executed with session context.",
1378        )
1379    }
1380
1381    async fn execute_with_context(
1382        &self,
1383        arguments: Value,
1384        context: &ToolContext,
1385    ) -> ToolExecutionResult {
1386        let path = match arguments.get("path").and_then(|v| v.as_str()) {
1387            Some(path) => path,
1388            None => return ToolExecutionResult::tool_error("Missing required parameter: path"),
1389        };
1390        let expected_hash = match arguments.get("expected_hash").and_then(|v| v.as_str()) {
1391            Some(hash) => hash,
1392            None => {
1393                return ToolExecutionResult::tool_error(
1394                    "Missing required parameter: expected_hash",
1395                );
1396            }
1397        };
1398        let edits = match parse_text_edits(&arguments) {
1399            Ok(edits) => edits,
1400            Err(error) => return ToolExecutionResult::tool_error(error),
1401        };
1402
1403        let file_store = match &context.file_store {
1404            Some(store) => store,
1405            None => {
1406                return ToolExecutionResult::tool_error(
1407                    "File system not available in this context",
1408                );
1409            }
1410        };
1411
1412        // The store (MountFs in production) is the sole resolver: hand it the
1413        // raw path and it routes `/workspace`, the root mount, and relatives.
1414        let normalized_path = path.to_string();
1415        let display_path = fs_input_display_path(file_store.as_ref(), &normalized_path);
1416
1417        let existing = match file_store
1418            .read_file(context.session_id, &normalized_path)
1419            .await
1420        {
1421            Ok(Some(file)) => file,
1422            Ok(None) => {
1423                return ToolExecutionResult::tool_error(format!(
1424                    "File not found: {}",
1425                    display_path
1426                ));
1427            }
1428            Err(e) => return ToolExecutionResult::internal_error(e),
1429        };
1430
1431        if existing.is_directory {
1432            return ToolExecutionResult::tool_error(format!(
1433                "Path '{}' is a directory, not a file. Use list_directory instead.",
1434                display_path
1435            ));
1436        }
1437
1438        if existing.encoding != "text" {
1439            return ToolExecutionResult::tool_error(format!(
1440                "File '{}' is not a text file. edit_file only supports text files; use write_file for binary/base64 content.",
1441                display_path
1442            ));
1443        }
1444
1445        let current_hash = match session_file_content_hash(&existing) {
1446            Ok(hash) => hash,
1447            Err(e) => return ToolExecutionResult::internal_error(e),
1448        };
1449        if expected_hash != current_hash {
1450            return ToolExecutionResult::tool_error(format!(
1451                "File '{}' changed since the last read. Expected {}, found {}. Read the file again before editing.",
1452                display_path, expected_hash, current_hash
1453            ));
1454        }
1455
1456        let current_content = existing.content.unwrap_or_default();
1457        let (updated_content, applied_edits) = match apply_text_edits(&current_content, &edits) {
1458            Ok(result) => result,
1459            Err(error) => return ToolExecutionResult::tool_error(error),
1460        };
1461
1462        let first_changed_line = first_changed_line(&current_content, &updated_content);
1463        let (diff, diff_truncated) = truncate_diff(render_unified_diff(
1464            &display_path,
1465            &current_content,
1466            &updated_content,
1467        ));
1468
1469        match file_store
1470            .write_file_if_content_matches(
1471                context.session_id,
1472                &normalized_path,
1473                &current_content,
1474                "text",
1475                &updated_content,
1476                "text",
1477            )
1478            .await
1479        {
1480            Ok(updated_file) => {
1481                let Some(updated_file) = updated_file else {
1482                    let latest = match file_store
1483                        .read_file(context.session_id, &normalized_path)
1484                        .await
1485                    {
1486                        Ok(file) => file,
1487                        Err(e) => return ToolExecutionResult::internal_error(e),
1488                    };
1489
1490                    return match latest {
1491                        Some(file) if file.is_directory => {
1492                            ToolExecutionResult::tool_error(format!(
1493                                "Path '{}' is a directory, not a file. Use list_directory instead.",
1494                                display_path
1495                            ))
1496                        }
1497                        Some(file) if file.is_readonly => ToolExecutionResult::tool_error(format!(
1498                            "Cannot modify readonly file: {}",
1499                            display_path
1500                        )),
1501                        Some(file) if file.encoding != "text" => {
1502                            ToolExecutionResult::tool_error(format!(
1503                                "File '{}' is not a text file. edit_file only supports text files; use write_file for binary/base64 content.",
1504                                display_path
1505                            ))
1506                        }
1507                        Some(file) => {
1508                            let latest_hash = match session_file_content_hash(&file) {
1509                                Ok(hash) => hash,
1510                                Err(e) => return ToolExecutionResult::internal_error(e),
1511                            };
1512                            ToolExecutionResult::tool_error(format!(
1513                                "File '{}' changed since the last read. Expected {}, found {}. Read the file again before editing.",
1514                                display_path, expected_hash, latest_hash
1515                            ))
1516                        }
1517                        None => ToolExecutionResult::tool_error(format!(
1518                            "File not found: {}",
1519                            display_path
1520                        )),
1521                    };
1522                };
1523
1524                let new_hash = match session_file_content_hash(&updated_file) {
1525                    Ok(hash) => hash,
1526                    Err(e) => return ToolExecutionResult::internal_error(e),
1527                };
1528                ToolExecutionResult::success(json!({
1529                    "path": display_path,
1530                    "size_bytes": updated_file.size_bytes,
1531                    "content_hash": new_hash,
1532                    "previous_content_hash": current_hash,
1533                    "applied_edits": applied_edits,
1534                    "first_changed_line": first_changed_line,
1535                    "diff": diff,
1536                    "diff_truncated": diff_truncated
1537                }))
1538            }
1539            Err(e) => write_failure_result(e),
1540        }
1541    }
1542
1543    fn requires_context(&self) -> bool {
1544        true
1545    }
1546}
1547
1548// ============================================================================
1549// ListDirectoryTool
1550// ============================================================================
1551
1552/// Tool to list directory contents
1553pub struct ListDirectoryTool;
1554
1555#[async_trait]
1556impl Tool for ListDirectoryTool {
1557    fn narrate(
1558        &self,
1559        tool_call: &crate::tool_types::ToolCall,
1560        phase: crate::tool_narration::ToolNarrationPhase,
1561        locale: Option<&str>,
1562        ctx: crate::tool_narration::ToolNarrationContext<'_>,
1563    ) -> Option<String> {
1564        Some(crate::tool_narration::narrate_list_directory(
1565            &tool_call.arguments,
1566            phase,
1567            locale,
1568            ctx,
1569        ))
1570    }
1571
1572    fn name(&self) -> &str {
1573        "list_directory"
1574    }
1575
1576    fn display_name(&self) -> Option<&str> {
1577        Some("List Directory")
1578    }
1579
1580    fn description(&self) -> &str {
1581        "List files and directories at a given path. Returns file metadata including size and type."
1582    }
1583
1584    fn parameters_schema(&self) -> Value {
1585        list_directory_parameters_schema(&FilePathPresentation::vfs())
1586    }
1587
1588    fn hints(&self) -> ToolHints {
1589        ToolHints::default()
1590            .with_readonly(true)
1591            .with_idempotent(true)
1592    }
1593
1594    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1595        ToolExecutionResult::tool_error(
1596            "list_directory requires context. This tool must be executed with session context.",
1597        )
1598    }
1599
1600    async fn execute_with_context(
1601        &self,
1602        arguments: Value,
1603        context: &ToolContext,
1604    ) -> ToolExecutionResult {
1605        let offset = arguments
1606            .get("offset")
1607            .and_then(|v| v.as_u64())
1608            .unwrap_or(0) as usize;
1609        let limit = match arguments.get("limit").and_then(|v| v.as_u64()) {
1610            Some(0) => return ToolExecutionResult::tool_error("limit must be greater than 0"),
1611            Some(value) => (value as usize).min(LIST_DIRECTORY_MAX_LIMIT),
1612            None => LIST_DIRECTORY_DEFAULT_LIMIT,
1613        };
1614
1615        let file_store = match &context.file_store {
1616            Some(store) => store,
1617            None => {
1618                return ToolExecutionResult::tool_error(
1619                    "File system not available in this context",
1620                );
1621            }
1622        };
1623
1624        let path = arguments
1625            .get("path")
1626            .and_then(|v| v.as_str())
1627            .map(str::to_string)
1628            .unwrap_or_else(|| "/".to_string());
1629
1630        // Normalize path to strip /workspace prefix for storage
1631        // The store (MountFs in production) is the sole resolver: hand it the
1632        // raw path and it routes `/workspace`, the root mount, and relatives.
1633        let normalized_path = path.to_string();
1634        let display_path = fs_input_display_path(file_store.as_ref(), &normalized_path);
1635
1636        match file_store
1637            .list_directory(context.session_id, &normalized_path)
1638            .await
1639        {
1640            Ok(files) => {
1641                let total_count = files.len();
1642                let entries: Vec<Value> = files
1643                    .iter()
1644                    .skip(offset)
1645                    .take(limit)
1646                    .map(|f| {
1647                        json!({
1648                            "name": f.name,
1649                            "path": fs_display_path(file_store.as_ref(), &f.path),
1650                            "is_directory": f.is_directory,
1651                            "size_bytes": f.size_bytes,
1652                            "is_readonly": f.is_readonly
1653                        })
1654                    })
1655                    .collect();
1656
1657                let mut result = json!({
1658                    "path": display_path,
1659                    "entries": entries,
1660                    "count": entries.len(),
1661                    "total_count": total_count,
1662                    "offset": offset,
1663                    "limit": limit
1664                });
1665                let bytes_returned = serde_json::to_string(&entries)
1666                    .expect("list_directory entries always serialize")
1667                    .len();
1668                let next_offset = offset.saturating_add(entries.len());
1669                let truncation = if next_offset < total_count {
1670                    TruncationInfo::with_resume(
1671                        bytes_returned,
1672                        None,
1673                        next_offset as u64,
1674                        format!(
1675                            "call list_directory with offset={} to resume from item {}",
1676                            next_offset,
1677                            next_offset + 1
1678                        ),
1679                        TruncationReason::ItemCap,
1680                    )
1681                } else {
1682                    TruncationInfo::not_truncated(bytes_returned)
1683                };
1684                truncation.attach(&mut result);
1685                ToolExecutionResult::success(result)
1686            }
1687            Err(e) => match classify_fs_error(&e) {
1688                // A missing or non-directory listing target is the agent's to
1689                // fix; everything else is internal. EVE-645: typed seam.
1690                FileSystemErrorClass::NotFound | FileSystemErrorClass::NotADirectory => {
1691                    ToolExecutionResult::tool_error(e.to_string())
1692                }
1693                _ => ToolExecutionResult::internal_error(e),
1694            },
1695        }
1696    }
1697
1698    fn requires_context(&self) -> bool {
1699        true
1700    }
1701}
1702
1703// ============================================================================
1704// GrepFilesTool
1705// ============================================================================
1706
1707/// Tool to search files by pattern
1708pub struct GrepFilesTool;
1709
1710#[async_trait]
1711impl Tool for GrepFilesTool {
1712    fn narrate(
1713        &self,
1714        tool_call: &crate::tool_types::ToolCall,
1715        phase: crate::tool_narration::ToolNarrationPhase,
1716        locale: Option<&str>,
1717        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1718    ) -> Option<String> {
1719        Some(crate::tool_narration::narrate_grep_files(
1720            &tool_call.arguments,
1721            phase,
1722            locale,
1723        ))
1724    }
1725
1726    fn name(&self) -> &str {
1727        "grep_files"
1728    }
1729
1730    fn display_name(&self) -> Option<&str> {
1731        Some("Grep Files")
1732    }
1733
1734    fn description(&self) -> &str {
1735        "Search file contents using a Rust regex. Optionally returns bounded before/after context as merged blocks with numbered lines and explicit match markers. Offset and limit paginate matches, not context lines; output is capped at 64 KiB with resume metadata."
1736    }
1737
1738    fn parameters_schema(&self) -> Value {
1739        grep_files_parameters_schema()
1740    }
1741
1742    fn hints(&self) -> ToolHints {
1743        ToolHints::default()
1744            .with_readonly(true)
1745            .with_idempotent(true)
1746    }
1747
1748    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1749        ToolExecutionResult::tool_error(
1750            "grep_files requires context. This tool must be executed with session context.",
1751        )
1752    }
1753
1754    async fn execute_with_context(
1755        &self,
1756        arguments: Value,
1757        context: &ToolContext,
1758    ) -> ToolExecutionResult {
1759        let pattern = match arguments.get("pattern").and_then(|v| v.as_str()) {
1760            Some(p) => p,
1761            None => return ToolExecutionResult::tool_error("Missing required parameter: pattern"),
1762        };
1763
1764        let path_pattern = arguments.get("path_pattern").and_then(|v| v.as_str());
1765        let parse_context = |name: &str| -> Result<usize, String> {
1766            let Some(value) = arguments.get(name) else {
1767                return Ok(0);
1768            };
1769            let Some(value) = value.as_i64() else {
1770                return Err(format!("{name} must be a non-negative integer"));
1771            };
1772            if value < 0 {
1773                return Err(format!("{name} must be a non-negative integer"));
1774            }
1775            let value = value as usize;
1776            if value > crate::GREP_MAX_CONTEXT_LINES {
1777                return Err(format!(
1778                    "{name} must not exceed {}",
1779                    crate::GREP_MAX_CONTEXT_LINES
1780                ));
1781            }
1782            Ok(value)
1783        };
1784        let before_context = match parse_context("before_context") {
1785            Ok(value) => value,
1786            Err(error) => return ToolExecutionResult::tool_error(error),
1787        };
1788        let after_context = match parse_context("after_context") {
1789            Ok(value) => value,
1790            Err(error) => return ToolExecutionResult::tool_error(error),
1791        };
1792        let offset = arguments
1793            .get("offset")
1794            .and_then(|v| v.as_u64())
1795            .unwrap_or(0) as usize;
1796        let limit = match arguments.get("limit").and_then(|v| v.as_u64()) {
1797            Some(0) => return ToolExecutionResult::tool_error("limit must be greater than 0"),
1798            Some(value) => (value as usize).min(GREP_FILES_MAX_LIMIT),
1799            None => GREP_FILES_DEFAULT_LIMIT,
1800        };
1801
1802        let file_store = match &context.file_store {
1803            Some(store) => store,
1804            None => {
1805                return ToolExecutionResult::tool_error(
1806                    "File system not available in this context",
1807                );
1808            }
1809        };
1810
1811        match file_store
1812            .grep_files_with_options(
1813                context.session_id,
1814                pattern,
1815                &crate::GrepOptions {
1816                    path_pattern: path_pattern.map(ToString::to_string),
1817                    before_context,
1818                    after_context,
1819                    offset,
1820                    limit,
1821                    max_bytes: crate::GREP_MAX_RETURN_BYTES,
1822                },
1823            )
1824            .await
1825        {
1826            Ok(search) => {
1827                let results: Vec<Value> = search
1828                    .matches
1829                    .iter()
1830                    .map(|m| {
1831                        json!({
1832                            "path": fs_display_path(file_store.as_ref(), &m.path),
1833                            "line_number": m.line_number,
1834                            "line": m.line
1835                        })
1836                    })
1837                    .collect();
1838                let blocks: Vec<Value> = search
1839                    .blocks
1840                    .iter()
1841                    .map(|block| {
1842                        json!({
1843                            "path": fs_display_path(file_store.as_ref(), &block.path),
1844                            "start_line": block.start_line,
1845                            "end_line": block.end_line,
1846                            "match_line_numbers": block.match_line_numbers,
1847                            "lines": block.lines
1848                        })
1849                    })
1850                    .collect();
1851
1852                let mut result = json!({
1853                    "pattern": pattern,
1854                    "match_count": search.returned_matches,
1855                    "total_matches": search.total_matches,
1856                    "offset": offset,
1857                    "limit": limit
1858                });
1859                if before_context == 0 && after_context == 0 {
1860                    result["matches"] = Value::Array(results);
1861                } else {
1862                    result["blocks"] = Value::Array(blocks);
1863                }
1864                let truncation = if let Some(next_offset) = search.next_offset {
1865                    TruncationInfo::with_resume(
1866                        search.bytes_returned,
1867                        Some(search.bytes_total),
1868                        next_offset as u64,
1869                        format!(
1870                            "call grep_files with offset={} to resume from match {}",
1871                            next_offset,
1872                            next_offset + 1
1873                        ),
1874                        if search.byte_truncated {
1875                            TruncationReason::SizeCap
1876                        } else {
1877                            TruncationReason::LineCap
1878                        },
1879                    )
1880                } else if search.byte_truncated {
1881                    TruncationInfo::without_resume(
1882                        search.bytes_returned,
1883                        Some(search.bytes_total),
1884                        TruncationReason::SizeCap,
1885                    )
1886                } else {
1887                    TruncationInfo::not_truncated(search.bytes_returned)
1888                };
1889                truncation.attach(&mut result);
1890                ToolExecutionResult::success(result)
1891            }
1892            Err(e) => {
1893                let msg = e.to_string();
1894                if msg.contains("regex") || msg.contains("pattern") {
1895                    ToolExecutionResult::tool_error(format!("Invalid regex pattern: {}", msg))
1896                } else {
1897                    ToolExecutionResult::internal_error(e)
1898                }
1899            }
1900        }
1901    }
1902
1903    fn requires_context(&self) -> bool {
1904        true
1905    }
1906}
1907
1908// ============================================================================
1909// DeleteFileTool
1910// ============================================================================
1911
1912/// Tool to delete a file or directory
1913pub struct DeleteFileTool;
1914
1915#[async_trait]
1916impl Tool for DeleteFileTool {
1917    fn narrate(
1918        &self,
1919        tool_call: &crate::tool_types::ToolCall,
1920        phase: crate::tool_narration::ToolNarrationPhase,
1921        locale: Option<&str>,
1922        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1923    ) -> Option<String> {
1924        Some(crate::tool_narration::narrate_delete_file(
1925            &tool_call.arguments,
1926            phase,
1927            locale,
1928        ))
1929    }
1930
1931    fn name(&self) -> &str {
1932        "delete_file"
1933    }
1934
1935    fn display_name(&self) -> Option<&str> {
1936        Some("Delete File")
1937    }
1938
1939    fn description(&self) -> &str {
1940        "Delete a file or directory. Use recursive=true to delete non-empty directories."
1941    }
1942
1943    fn parameters_schema(&self) -> Value {
1944        delete_file_parameters_schema(&FilePathPresentation::vfs())
1945    }
1946
1947    fn hints(&self) -> ToolHints {
1948        // Mutates the shared session workspace: serialize against other
1949        // workspace writes (and bash) within a batch to avoid races.
1950        ToolHints::default()
1951            .with_destructive(true)
1952            .with_idempotent(true)
1953            .with_concurrency_class("session_workspace")
1954    }
1955
1956    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
1957        ToolExecutionResult::tool_error(
1958            "delete_file requires context. This tool must be executed with session context.",
1959        )
1960    }
1961
1962    async fn execute_with_context(
1963        &self,
1964        arguments: Value,
1965        context: &ToolContext,
1966    ) -> ToolExecutionResult {
1967        let path = match arguments.get("path").and_then(|v| v.as_str()) {
1968            Some(p) => p,
1969            None => return ToolExecutionResult::tool_error("Missing required parameter: path"),
1970        };
1971
1972        let recursive = arguments
1973            .get("recursive")
1974            .and_then(|v| v.as_bool())
1975            .unwrap_or(false);
1976
1977        let file_store = match &context.file_store {
1978            Some(store) => store,
1979            None => {
1980                return ToolExecutionResult::tool_error(
1981                    "File system not available in this context",
1982                );
1983            }
1984        };
1985
1986        // Normalize path to strip /workspace prefix for storage
1987        // The store (MountFs in production) is the sole resolver: hand it the
1988        // raw path and it routes `/workspace`, the root mount, and relatives.
1989        let normalized_path = path.to_string();
1990        let display_path = fs_input_display_path(file_store.as_ref(), &normalized_path);
1991
1992        match file_store
1993            .delete_file(context.session_id, &normalized_path, recursive)
1994            .await
1995        {
1996            Ok(deleted) => {
1997                if deleted {
1998                    ToolExecutionResult::success(json!({
1999                        "path": display_path,
2000                        "deleted": true
2001                    }))
2002                } else {
2003                    ToolExecutionResult::tool_error(format!("File not found: {}", display_path))
2004                }
2005            }
2006            Err(e) => match classify_fs_error(&e) {
2007                // A non-empty directory deleted without `recursive` is the
2008                // agent's to fix; everything else is internal. EVE-645: typed
2009                // seam. (The legacy `recursive` substring maps to NotEmpty so a
2010                // "without recursive flag" / "recursive delete failed" message
2011                // keeps surfacing as a tool error.)
2012                FileSystemErrorClass::NotEmpty => ToolExecutionResult::tool_error(e.to_string()),
2013                _ => ToolExecutionResult::internal_error(e),
2014            },
2015        }
2016    }
2017
2018    fn requires_context(&self) -> bool {
2019        true
2020    }
2021}
2022
2023// ============================================================================
2024// StatFileTool
2025// ============================================================================
2026
2027/// Tool to get file metadata
2028pub struct StatFileTool;
2029
2030#[async_trait]
2031impl Tool for StatFileTool {
2032    fn narrate(
2033        &self,
2034        tool_call: &crate::tool_types::ToolCall,
2035        phase: crate::tool_narration::ToolNarrationPhase,
2036        locale: Option<&str>,
2037        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
2038    ) -> Option<String> {
2039        Some(crate::tool_narration::narrate_stat_file(
2040            &tool_call.arguments,
2041            phase,
2042            locale,
2043        ))
2044    }
2045
2046    fn name(&self) -> &str {
2047        "stat_file"
2048    }
2049
2050    fn display_name(&self) -> Option<&str> {
2051        Some("File Info")
2052    }
2053
2054    fn description(&self) -> &str {
2055        "Get metadata about a file or directory (exists, size, type, dates)."
2056    }
2057
2058    fn parameters_schema(&self) -> Value {
2059        stat_file_parameters_schema(&FilePathPresentation::vfs())
2060    }
2061
2062    fn hints(&self) -> ToolHints {
2063        ToolHints::default()
2064            .with_readonly(true)
2065            .with_idempotent(true)
2066    }
2067
2068    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
2069        ToolExecutionResult::tool_error(
2070            "stat_file requires context. This tool must be executed with session context.",
2071        )
2072    }
2073
2074    async fn execute_with_context(
2075        &self,
2076        arguments: Value,
2077        context: &ToolContext,
2078    ) -> ToolExecutionResult {
2079        let path = match arguments.get("path").and_then(|v| v.as_str()) {
2080            Some(p) => p,
2081            None => return ToolExecutionResult::tool_error("Missing required parameter: path"),
2082        };
2083
2084        let file_store = match &context.file_store {
2085            Some(store) => store,
2086            None => {
2087                return ToolExecutionResult::tool_error(
2088                    "File system not available in this context",
2089                );
2090            }
2091        };
2092
2093        // Normalize path to strip /workspace prefix for storage
2094        // The store (MountFs in production) is the sole resolver: hand it the
2095        // raw path and it routes `/workspace`, the root mount, and relatives.
2096        let normalized_path = path.to_string();
2097        let display_path = fs_input_display_path(file_store.as_ref(), &normalized_path);
2098
2099        match file_store
2100            .stat_file(context.session_id, &normalized_path)
2101            .await
2102        {
2103            Ok(Some(stat)) => ToolExecutionResult::success(json!({
2104                "path": fs_display_path(file_store.as_ref(), &stat.path),
2105                "name": stat.name,
2106                "exists": true,
2107                "is_directory": stat.is_directory,
2108                "is_readonly": stat.is_readonly,
2109                "size_bytes": stat.size_bytes,
2110                "created_at": stat.created_at.to_rfc3339(),
2111                "updated_at": stat.updated_at.to_rfc3339()
2112            })),
2113            Ok(None) => ToolExecutionResult::success(json!({
2114                "path": display_path,
2115                "exists": false
2116            })),
2117            Err(e) => ToolExecutionResult::internal_error(e),
2118        }
2119    }
2120
2121    fn requires_context(&self) -> bool {
2122        true
2123    }
2124}
2125
2126#[cfg(test)]
2127mod tests {
2128    use super::*;
2129    use crate::tool_narration::{ToolNarrationContext, ToolNarrationPhase};
2130    use crate::tool_types::ToolCall;
2131
2132    #[test]
2133    fn capability_narrates_its_own_tools_only() {
2134        let cap = FileSystemCapability;
2135        let read = ToolCall {
2136            id: "c1".to_string(),
2137            name: "read_file".to_string(),
2138            arguments: serde_json::json!({ "path": "/workspace/AGENTS.md" }),
2139        };
2140        assert_eq!(
2141            cap.narrate(
2142                None,
2143                &read,
2144                ToolNarrationPhase::Completed,
2145                None,
2146                ToolNarrationContext::default()
2147            ),
2148            Some("Read AGENTS.md".to_string())
2149        );
2150        // A tool this capability does not own returns None for its owner to handle.
2151        let bash = ToolCall {
2152            id: "c2".to_string(),
2153            name: "bash".to_string(),
2154            arguments: serde_json::json!({ "command": "ls" }),
2155        };
2156        assert_eq!(
2157            cap.narrate(
2158                None,
2159                &bash,
2160                ToolNarrationPhase::Started,
2161                None,
2162                ToolNarrationContext::default()
2163            ),
2164            None
2165        );
2166    }
2167    use crate::error::Result;
2168    use crate::session_file::{FileInfo, FileStat, GrepMatch, SessionFile};
2169    use crate::traits::SessionFileSystem;
2170    use crate::typed_id::SessionId;
2171    use chrono::Utc;
2172    use std::collections::HashMap;
2173    use std::sync::{Arc, Mutex};
2174    use uuid::Uuid;
2175
2176    #[derive(Debug, Clone)]
2177    struct StoredFile {
2178        content: Option<String>,
2179        encoding: String,
2180        is_directory: bool,
2181        is_readonly: bool,
2182        created_at: chrono::DateTime<Utc>,
2183        updated_at: chrono::DateTime<Utc>,
2184    }
2185
2186    impl StoredFile {
2187        fn text(content: &str) -> Self {
2188            let now = Utc::now();
2189            Self {
2190                content: Some(content.to_string()),
2191                encoding: "text".to_string(),
2192                is_directory: false,
2193                is_readonly: false,
2194                created_at: now,
2195                updated_at: now,
2196            }
2197        }
2198
2199        fn base64(content: &str) -> Self {
2200            let now = Utc::now();
2201            Self {
2202                content: Some(content.to_string()),
2203                encoding: "base64".to_string(),
2204                is_directory: false,
2205                is_readonly: false,
2206                created_at: now,
2207                updated_at: now,
2208            }
2209        }
2210
2211        fn directory() -> Self {
2212            let now = Utc::now();
2213            Self {
2214                content: None,
2215                encoding: "text".to_string(),
2216                is_directory: true,
2217                is_readonly: false,
2218                created_at: now,
2219                updated_at: now,
2220            }
2221        }
2222
2223        fn readonly_text(content: &str) -> Self {
2224            let mut entry = Self::text(content);
2225            entry.is_readonly = true;
2226            entry
2227        }
2228    }
2229
2230    #[derive(Default)]
2231    struct MockFileStore {
2232        files: Mutex<HashMap<String, StoredFile>>,
2233        conditional_write_injections: Mutex<HashMap<String, StoredFile>>,
2234        display_root: Option<String>,
2235    }
2236
2237    impl MockFileStore {
2238        fn with_display_root(root: &str) -> Self {
2239            Self {
2240                display_root: Some(root.to_string()),
2241                ..Self::default()
2242            }
2243        }
2244
2245        fn insert(&self, path: &str, file: StoredFile) {
2246            self.files.lock().unwrap().insert(path.to_string(), file);
2247        }
2248
2249        fn add_text_file(&self, path: &str, content: &str) {
2250            self.insert(path, StoredFile::text(content));
2251        }
2252
2253        fn add_base64_file(&self, path: &str, content: &str) {
2254            self.insert(path, StoredFile::base64(content));
2255        }
2256
2257        fn add_directory(&self, path: &str) {
2258            self.insert(path, StoredFile::directory());
2259        }
2260
2261        fn add_readonly_text_file(&self, path: &str, content: &str) {
2262            self.insert(path, StoredFile::readonly_text(content));
2263        }
2264
2265        fn content(&self, path: &str) -> Option<String> {
2266            self.files
2267                .lock()
2268                .unwrap()
2269                .get(path)
2270                .and_then(|file| file.content.clone())
2271        }
2272
2273        fn inject_conditional_write_change(&self, path: &str, file: StoredFile) {
2274            self.conditional_write_injections
2275                .lock()
2276                .unwrap()
2277                .insert(path.to_string(), file);
2278        }
2279
2280        fn entry_to_session_file(path: &str, entry: &StoredFile) -> SessionFile {
2281            let size_bytes = entry
2282                .content
2283                .as_deref()
2284                .map(|content| {
2285                    SessionFile::decode_content(content, &entry.encoding)
2286                        .map(|bytes| bytes.len() as i64)
2287                        .unwrap_or(content.len() as i64)
2288                })
2289                .unwrap_or(0);
2290
2291            SessionFile {
2292                id: Uuid::new_v4(),
2293                session_id: Uuid::nil(),
2294                path: path.to_string(),
2295                name: path.rsplit('/').next().unwrap_or("").to_string(),
2296                content: entry.content.clone(),
2297                encoding: entry.encoding.clone(),
2298                is_directory: entry.is_directory,
2299                is_readonly: entry.is_readonly,
2300                size_bytes,
2301                created_at: entry.created_at,
2302                updated_at: entry.updated_at,
2303            }
2304        }
2305    }
2306
2307    #[async_trait]
2308    impl SessionFileSystem for MockFileStore {
2309        fn display_root(&self) -> String {
2310            self.display_root
2311                .clone()
2312                .unwrap_or_else(|| WORKSPACE_PREFIX.to_string())
2313        }
2314
2315        fn display_path(&self, path: &str) -> String {
2316            match &self.display_root {
2317                Some(root) if path == "/" => root.clone(),
2318                Some(root) => format!(
2319                    "{}/{}",
2320                    root.trim_end_matches('/'),
2321                    path.trim_start_matches('/')
2322                ),
2323                None if path == "/" => WORKSPACE_PREFIX.to_string(),
2324                None if path.starts_with('/') => format!("{WORKSPACE_PREFIX}{path}"),
2325                None => format!("{WORKSPACE_PREFIX}/{path}"),
2326            }
2327        }
2328
2329        fn is_mount_resolver(&self) -> bool {
2330            false
2331        }
2332
2333        async fn read_file(
2334            &self,
2335            _session_id: SessionId,
2336            path: &str,
2337        ) -> Result<Option<SessionFile>> {
2338            let files = self.files.lock().unwrap();
2339            Ok(files
2340                .get(path)
2341                .map(|entry| Self::entry_to_session_file(path, entry)))
2342        }
2343
2344        async fn write_file(
2345            &self,
2346            _session_id: SessionId,
2347            path: &str,
2348            content: &str,
2349            encoding: &str,
2350        ) -> Result<SessionFile> {
2351            let mut files = self.files.lock().unwrap();
2352            if let Some(existing) = files.get(path) {
2353                if existing.is_directory {
2354                    return Err(anyhow::anyhow!("Path '{}' is a directory", path).into());
2355                }
2356                if existing.is_readonly {
2357                    return Err(anyhow::anyhow!("File '{}' is readonly", path).into());
2358                }
2359            }
2360
2361            let created_at = files
2362                .get(path)
2363                .map(|entry| entry.created_at)
2364                .unwrap_or_else(Utc::now);
2365            let entry = StoredFile {
2366                content: Some(content.to_string()),
2367                encoding: encoding.to_string(),
2368                is_directory: false,
2369                is_readonly: false,
2370                created_at,
2371                updated_at: Utc::now(),
2372            };
2373            files.insert(path.to_string(), entry.clone());
2374            Ok(Self::entry_to_session_file(path, &entry))
2375        }
2376
2377        async fn delete_file(
2378            &self,
2379            _session_id: SessionId,
2380            path: &str,
2381            _recursive: bool,
2382        ) -> Result<bool> {
2383            Ok(self.files.lock().unwrap().remove(path).is_some())
2384        }
2385
2386        async fn list_directory(
2387            &self,
2388            _session_id: SessionId,
2389            path: &str,
2390        ) -> Result<Vec<FileInfo>> {
2391            let prefix = if path == "/" {
2392                "/".to_string()
2393            } else {
2394                format!("{}/", path.trim_end_matches('/'))
2395            };
2396            let files = self.files.lock().unwrap();
2397            let mut entries: Vec<FileInfo> = files
2398                .iter()
2399                .filter_map(|(entry_path, entry)| {
2400                    if path != "/" && entry_path == path {
2401                        return None;
2402                    }
2403                    let rest = entry_path.strip_prefix(&prefix)?;
2404                    if rest.is_empty() || rest.contains('/') {
2405                        return None;
2406                    }
2407                    Some(FileInfo {
2408                        id: Uuid::new_v4(),
2409                        session_id: Uuid::nil(),
2410                        name: rest.to_string(),
2411                        path: entry_path.clone(),
2412                        is_directory: entry.is_directory,
2413                        is_readonly: entry.is_readonly,
2414                        size_bytes: entry
2415                            .content
2416                            .as_ref()
2417                            .map(|content| content.len() as i64)
2418                            .unwrap_or(0),
2419                        created_at: entry.created_at,
2420                        updated_at: entry.updated_at,
2421                    })
2422                })
2423                .collect();
2424            entries.sort_by(|a, b| a.path.cmp(&b.path));
2425            Ok(entries)
2426        }
2427
2428        async fn stat_file(&self, _session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
2429            let files = self.files.lock().unwrap();
2430            Ok(files.get(path).map(|entry| FileStat {
2431                path: path.to_string(),
2432                name: path.rsplit('/').next().unwrap_or("").to_string(),
2433                is_directory: entry.is_directory,
2434                is_readonly: entry.is_readonly,
2435                size_bytes: entry
2436                    .content
2437                    .as_ref()
2438                    .map(|content| content.len() as i64)
2439                    .unwrap_or(0),
2440                created_at: entry.created_at,
2441                updated_at: entry.updated_at,
2442            }))
2443        }
2444
2445        async fn grep_files(
2446            &self,
2447            _session_id: SessionId,
2448            pattern: &str,
2449            _path_pattern: Option<&str>,
2450        ) -> Result<Vec<GrepMatch>> {
2451            let files = self.files.lock().unwrap();
2452            let mut matches = Vec::new();
2453            for (path, entry) in files.iter() {
2454                if entry.is_directory || entry.encoding != "text" {
2455                    continue;
2456                }
2457                let Some(content) = entry.content.as_deref() else {
2458                    continue;
2459                };
2460                for (idx, line) in content.lines().enumerate() {
2461                    if line.contains(pattern) {
2462                        matches.push(GrepMatch {
2463                            path: path.clone(),
2464                            line_number: idx + 1,
2465                            line: line.to_string(),
2466                        });
2467                    }
2468                }
2469            }
2470            matches.sort_by(|a, b| {
2471                a.path
2472                    .cmp(&b.path)
2473                    .then_with(|| a.line_number.cmp(&b.line_number))
2474            });
2475            Ok(matches)
2476        }
2477
2478        async fn grep_files_with_options(
2479            &self,
2480            _session_id: SessionId,
2481            pattern: &str,
2482            options: &crate::GrepOptions,
2483        ) -> Result<crate::GrepSearchResult> {
2484            let regex = regex::Regex::new(pattern).map_err(|error| {
2485                crate::AgentLoopError::tool(format!("Invalid regex pattern: {error}"))
2486            })?;
2487            let path_matcher = options
2488                .path_pattern
2489                .as_deref()
2490                .map(crate::session_path::GrepPathPattern::new)
2491                .transpose()?;
2492            let files = self
2493                .files
2494                .lock()
2495                .unwrap()
2496                .iter()
2497                .filter(|(path, entry)| {
2498                    !entry.is_directory
2499                        && entry.encoding == "text"
2500                        && path_matcher
2501                            .as_ref()
2502                            .is_none_or(|matcher| matcher.is_match(path))
2503                })
2504                .filter_map(|(path, entry)| {
2505                    entry
2506                        .content
2507                        .as_ref()
2508                        .map(|content| (path.clone(), content.clone()))
2509                })
2510                .collect();
2511            Ok(crate::session_file::build_grep_search_result(
2512                files, &regex, options,
2513            ))
2514        }
2515
2516        async fn create_directory(&self, _session_id: SessionId, path: &str) -> Result<FileInfo> {
2517            self.add_directory(path);
2518            Ok(FileInfo {
2519                id: Uuid::new_v4(),
2520                session_id: Uuid::nil(),
2521                path: path.to_string(),
2522                name: path.rsplit('/').next().unwrap_or("").to_string(),
2523                is_directory: true,
2524                is_readonly: false,
2525                size_bytes: 0,
2526                created_at: Utc::now(),
2527                updated_at: Utc::now(),
2528            })
2529        }
2530
2531        async fn write_file_if_content_matches(
2532            &self,
2533            _session_id: SessionId,
2534            path: &str,
2535            expected_content: &str,
2536            expected_encoding: &str,
2537            content: &str,
2538            encoding: &str,
2539        ) -> Result<Option<SessionFile>> {
2540            let mut files = self.files.lock().unwrap();
2541            if let Some(injected) = self
2542                .conditional_write_injections
2543                .lock()
2544                .unwrap()
2545                .remove(path)
2546            {
2547                files.insert(path.to_string(), injected);
2548            }
2549
2550            let Some(existing) = files.get(path).cloned() else {
2551                return Ok(None);
2552            };
2553
2554            if existing.is_directory
2555                || existing.is_readonly
2556                || existing.encoding != expected_encoding
2557                || existing.content.unwrap_or_default() != expected_content
2558            {
2559                return Ok(None);
2560            }
2561
2562            let entry = StoredFile {
2563                content: Some(content.to_string()),
2564                encoding: encoding.to_string(),
2565                is_directory: false,
2566                is_readonly: false,
2567                created_at: existing.created_at,
2568                updated_at: Utc::now(),
2569            };
2570            files.insert(path.to_string(), entry.clone());
2571            Ok(Some(Self::entry_to_session_file(path, &entry)))
2572        }
2573    }
2574
2575    fn make_context(file_store: Arc<MockFileStore>) -> ToolContext {
2576        // Wrap in MountFs exactly as production does, so the file tools resolve
2577        // `/workspace` and the root mount through the same path they use live.
2578        ToolContext::with_file_store(SessionId::new(), crate::mount_fs::MountFs::wrap(file_store))
2579    }
2580
2581    fn expect_success(result: ToolExecutionResult) -> Value {
2582        match result {
2583            ToolExecutionResult::Success(value) => value,
2584            ToolExecutionResult::SuccessWithImages { result, .. } => result,
2585            other => panic!("Expected success, got {other:?}"),
2586        }
2587    }
2588
2589    fn expect_tool_error(result: ToolExecutionResult) -> String {
2590        match result {
2591            ToolExecutionResult::ToolError(message) => message,
2592            other => panic!("Expected tool error, got {other:?}"),
2593        }
2594    }
2595
2596    async fn read_hash(context: &ToolContext, path: &str) -> String {
2597        let result = ReadFileTool
2598            .execute_with_context(json!({ "path": path }), context)
2599            .await;
2600        expect_success(result)["content_hash"]
2601            .as_str()
2602            .unwrap()
2603            .to_string()
2604    }
2605
2606    // Path normalization now lives in `session_path` (and the resolver
2607    // `MountFs`), exercised there; the file tools just hand raw paths to the
2608    // store.
2609
2610    #[test]
2611    fn test_display_path_root_defaults_to_workspace_namespace() {
2612        let store = MockFileStore::default();
2613        assert_eq!(fs_display_path(&store, "/"), "/workspace");
2614    }
2615
2616    #[test]
2617    fn test_display_path_file_defaults_to_workspace_namespace() {
2618        let store = MockFileStore::default();
2619        assert_eq!(fs_display_path(&store, "/test.txt"), "/workspace/test.txt");
2620    }
2621
2622    #[test]
2623    fn test_display_path_nested_defaults_to_workspace_namespace() {
2624        let store = MockFileStore::default();
2625        assert_eq!(
2626            fs_display_path(&store, "/foo/bar.txt"),
2627            "/workspace/foo/bar.txt"
2628        );
2629    }
2630
2631    #[test]
2632    fn test_display_path_no_leading_slash_defaults_to_workspace_namespace() {
2633        let store = MockFileStore::default();
2634        assert_eq!(fs_display_path(&store, "test.txt"), "/workspace/test.txt");
2635    }
2636
2637    #[tokio::test]
2638    async fn read_file_uses_mountfs_workspace_display_path() {
2639        // File tools run behind MountFs in production; mounted real-disk stores
2640        // must not leak host-absolute roots to model-visible output.
2641        let store = Arc::new(MockFileStore::with_display_root("/host/repo"));
2642        store.add_text_file("/notes.txt", "hello");
2643        let context = make_context(store);
2644
2645        let result = ReadFileTool
2646            .execute_with_context(json!({ "path": "/workspace/notes.txt" }), &context)
2647            .await;
2648        let value = expect_success(result);
2649
2650        assert_eq!(value["path"], "/workspace/notes.txt");
2651    }
2652
2653    #[test]
2654    fn test_parse_text_edits_coerces_mixed_modes() {
2655        // EVE-620: the schema is edits[]-only, but a mixed-mode call (top-level
2656        // old_text/new_text AND a non-empty edits[]) must be coerced rather than
2657        // rejected — the top-level pair is folded in as a leading edit.
2658        let edits = parse_text_edits(&json!({
2659            "old_text": "a",
2660            "new_text": "b",
2661            "edits": [{"old_text": "c", "new_text": "d"}]
2662        }))
2663        .expect("mixed-mode call should be coerced, not rejected");
2664
2665        assert_eq!(edits.len(), 2);
2666        assert_eq!(edits[0].old_text, "a");
2667        assert_eq!(edits[0].new_text, "b");
2668        assert_eq!(edits[1].old_text, "c");
2669        assert_eq!(edits[1].new_text, "d");
2670    }
2671
2672    #[test]
2673    fn test_parse_text_edits_dedupes_duplicated_top_level_edit() {
2674        // EVE-620: gpt-5.5 duplicates the same edit into the scalar fields and
2675        // edits[]. Folding both verbatim would create two identical edits that
2676        // match the same span and trip the overlap check — dedup keeps it to one.
2677        let edits = parse_text_edits(&json!({
2678            "old_text": "a",
2679            "new_text": "b",
2680            "edits": [{"old_text": "a", "new_text": "b"}]
2681        }))
2682        .expect("duplicated mixed-mode call should be coerced to a single edit");
2683
2684        assert_eq!(edits.len(), 1);
2685        assert_eq!(edits[0].old_text, "a");
2686        assert_eq!(edits[0].new_text, "b");
2687    }
2688
2689    #[test]
2690    fn test_parse_text_edits_coerces_top_level_only() {
2691        // Backward-compat: a legacy call with only top-level scalars (no edits[])
2692        // is folded into a single edit instead of rejected.
2693        let edits = parse_text_edits(&json!({
2694            "old_text": "hello",
2695            "new_text": "world"
2696        }))
2697        .expect("legacy top-level scalars should be coerced into edits[]");
2698        assert_eq!(edits.len(), 1);
2699        assert_eq!(edits[0].old_text, "hello");
2700        assert_eq!(edits[0].new_text, "world");
2701    }
2702
2703    #[test]
2704    fn test_parse_text_edits_allows_legacy_top_level_deletion() {
2705        // Backward-compat: an explicit empty-string replacement is still a valid
2706        // deletion edit when both legacy scalar fields are well-formed strings.
2707        let edits = parse_text_edits(&json!({
2708            "old_text": "remove me",
2709            "new_text": ""
2710        }))
2711        .expect("explicit legacy deletion edit should be accepted");
2712        assert_eq!(edits.len(), 1);
2713        assert_eq!(edits[0].old_text, "remove me");
2714        assert_eq!(edits[0].new_text, "");
2715    }
2716
2717    #[test]
2718    fn test_parse_text_edits_rejects_malformed_legacy_top_level_new_text() {
2719        let missing_err = parse_text_edits(&json!({
2720            "old_text": "remove me"
2721        }))
2722        .unwrap_err();
2723        assert!(
2724            missing_err.contains("new_text"),
2725            "error should mention new_text: {missing_err}"
2726        );
2727
2728        let non_string_err = parse_text_edits(&json!({
2729            "old_text": "remove me",
2730            "new_text": null
2731        }))
2732        .unwrap_err();
2733        assert!(
2734            non_string_err.contains("new_text"),
2735            "error should mention new_text: {non_string_err}"
2736        );
2737    }
2738
2739    #[test]
2740    fn test_parse_text_edits_requires_edits() {
2741        // No edits[] and no usable top-level pair is a hard error.
2742        let err = parse_text_edits(&json!({})).unwrap_err();
2743        assert!(err.contains("edits"), "error: {err}");
2744    }
2745
2746    #[test]
2747    fn test_parse_text_edits_accepts_batch() {
2748        let edits = parse_text_edits(&json!({
2749            "edits": [
2750                {"old_text": "a", "new_text": "1"},
2751                {"old_text": "b", "new_text": "2"}
2752            ]
2753        }))
2754        .expect("batch mode should be accepted");
2755        assert_eq!(edits.len(), 2);
2756        assert_eq!(edits[1].old_text, "b");
2757        assert_eq!(edits[1].new_text, "2");
2758    }
2759
2760    #[test]
2761    fn test_parse_text_edits_allows_empty_single_placeholders_with_batch() {
2762        // EVE-498 compat: some models emit empty top-level placeholders alongside a
2763        // real edits[]. That is treated as batch mode, not an ambiguous mixed call.
2764        let edits = parse_text_edits(&json!({
2765            "old_text": "",
2766            "new_text": "",
2767            "edits": [{"old_text": "c", "new_text": "d"}]
2768        }))
2769        .expect("empty placeholders + batch should be accepted as batch");
2770        assert_eq!(edits.len(), 1);
2771        assert_eq!(edits[0].old_text, "c");
2772        assert_eq!(edits[0].new_text, "d");
2773    }
2774
2775    #[test]
2776    fn test_apply_text_edits_rejects_overlaps() {
2777        let result = apply_text_edits(
2778            "abcdef",
2779            &[
2780                TextEdit {
2781                    old_text: "abcd".to_string(),
2782                    new_text: "wxyz".to_string(),
2783                },
2784                TextEdit {
2785                    old_text: "cdef".to_string(),
2786                    new_text: "1234".to_string(),
2787                },
2788            ],
2789        );
2790
2791        assert_eq!(result.unwrap_err(), "Edits overlap in the target file");
2792    }
2793
2794    // Metadata and tool-list constants are covered registry-wide by
2795    // `builtin_capabilities_satisfy_registry_invariants` in `capabilities::tests`;
2796    // the per-capability constant mirrors were removed.
2797
2798    #[tokio::test]
2799    async fn test_capability_has_system_prompt() {
2800        let cap = FileSystemCapability;
2801        let ctx = SystemPromptContext::without_file_store(SessionId::new());
2802        let prompt = cap.system_prompt_contribution(&ctx).await.unwrap();
2803        assert!(prompt.contains("/workspace"));
2804        assert!(prompt.contains("File reading economy"));
2805        assert!(prompt.contains("offset"));
2806        assert!(prompt.contains("total_lines"));
2807    }
2808
2809    #[tokio::test]
2810    async fn system_prompt_uses_mounted_workspace_display_root() {
2811        let cap = FileSystemCapability;
2812        let store = Arc::new(MockFileStore::with_display_root("/host/repo"));
2813        let mounted = crate::mount_fs::MountFs::wrap(store);
2814        let ctx = SystemPromptContext {
2815            session_id: SessionId::new(),
2816            locale: None,
2817            file_store: Some(mounted),
2818            model: None,
2819        };
2820
2821        let prompt = cap.system_prompt_contribution(&ctx).await.unwrap();
2822
2823        assert!(prompt.contains("Workspace root: `/workspace`"));
2824        assert!(!prompt.contains("/host/repo"));
2825    }
2826
2827    #[tokio::test]
2828    async fn system_prompt_backend_native_store_shows_host_root() {
2829        // #258 end-to-end: a local embedder whose MountFs opted into
2830        // backend-native display, wrapped by the same `scoped_prompt_file_store`
2831        // helper the reason/executor paths use, must surface real host paths in
2832        // the model-facing system prompt — not the `/workspace` alias.
2833        let cap = FileSystemCapability;
2834        let backend = Arc::new(MockFileStore::with_display_root("/host/repo"));
2835        let embedder_store: Arc<dyn SessionFileSystem> =
2836            Arc::new(crate::mount_fs::MountFs::new(backend).with_backend_display());
2837        let prompt_store = crate::mount_fs::scoped_prompt_file_store(
2838            embedder_store,
2839            crate::typed_id::WorkspaceId::from_seed(7),
2840        );
2841        let ctx = SystemPromptContext {
2842            session_id: SessionId::new(),
2843            locale: None,
2844            file_store: Some(prompt_store),
2845            model: None,
2846        };
2847
2848        let prompt = cap.system_prompt_contribution(&ctx).await.unwrap();
2849
2850        assert!(
2851            prompt.contains("Workspace root: `/host/repo`"),
2852            "system prompt should present the host root: {prompt}"
2853        );
2854        assert!(!prompt.contains("Workspace root: `/workspace`"));
2855    }
2856
2857    #[tokio::test]
2858    async fn system_prompt_escapes_store_display_root_xml_text() {
2859        let cap = FileSystemCapability;
2860        let store = Arc::new(MockFileStore::with_display_root(
2861            "/tmp/repo</capability><capability id=\"attacker\">",
2862        ));
2863        let ctx = SystemPromptContext {
2864            session_id: SessionId::new(),
2865            locale: None,
2866            file_store: Some(store),
2867            model: None,
2868        };
2869
2870        let prompt = cap.system_prompt_contribution(&ctx).await.unwrap();
2871
2872        assert!(prompt.contains(
2873            "Workspace root: `/tmp/repo&lt;/capability&gt;&lt;capability id=\"attacker\"&gt;`"
2874        ));
2875        assert!(!prompt.contains("</capability><capability id=\"attacker\">"));
2876    }
2877
2878    #[test]
2879    fn test_tool_schemas_have_no_top_level_composition_keywords() {
2880        // OpenAI Responses API rejects schemas with oneOf/anyOf/allOf/enum/not at top level
2881        let cap = FileSystemCapability;
2882        let forbidden = ["oneOf", "anyOf", "allOf", "enum", "not"];
2883        for tool in cap.tools() {
2884            let schema = tool.parameters_schema();
2885            for kw in &forbidden {
2886                assert!(
2887                    schema.get(*kw).is_none(),
2888                    "Tool '{}' schema has forbidden top-level keyword '{}'",
2889                    tool.name(),
2890                    kw
2891                );
2892            }
2893        }
2894    }
2895
2896    #[test]
2897    fn test_edit_file_schema_is_edits_only() {
2898        // EVE-620: the advertised schema must not offer top-level old_text/new_text
2899        // and must require edits[]. The single ambiguity-free shape is what keeps
2900        // structured-tool-call models from populating both fields on the first call.
2901        let schema = EditFileTool.parameters_schema();
2902        let props = schema["properties"].as_object().expect("properties object");
2903        assert!(
2904            !props.contains_key("old_text"),
2905            "schema must not advertise top-level old_text"
2906        );
2907        assert!(
2908            !props.contains_key("new_text"),
2909            "schema must not advertise top-level new_text"
2910        );
2911        let required: Vec<&str> = schema["required"]
2912            .as_array()
2913            .expect("required array")
2914            .iter()
2915            .map(|v| v.as_str().expect("required entries are strings"))
2916            .collect();
2917        assert!(required.contains(&"edits"), "edits[] must be required");
2918        assert!(required.contains(&"path"));
2919        assert!(required.contains(&"expected_hash"));
2920    }
2921
2922    #[tokio::test]
2923    async fn test_read_file_without_context() {
2924        let result = ReadFileTool.execute(json!({"path": "/test.txt"})).await;
2925        assert!(expect_tool_error(result).contains("requires context"));
2926    }
2927
2928    #[tokio::test]
2929    async fn test_write_file_without_context() {
2930        let result = WriteFileTool
2931            .execute(json!({"path": "/test.txt", "content": "hello"}))
2932            .await;
2933        assert!(expect_tool_error(result).contains("requires context"));
2934    }
2935
2936    #[tokio::test]
2937    async fn test_edit_file_without_context() {
2938        let result = EditFileTool
2939            .execute(json!({
2940                "path": "/test.txt",
2941                "expected_hash": "sha256:deadbeef",
2942                "old_text": "hello",
2943                "new_text": "goodbye"
2944            }))
2945            .await;
2946        assert!(expect_tool_error(result).contains("requires context"));
2947    }
2948
2949    #[tokio::test]
2950    async fn test_read_file_missing_path() {
2951        let context = ToolContext::new(SessionId::new());
2952        let result = ReadFileTool.execute_with_context(json!({}), &context).await;
2953        assert!(expect_tool_error(result).contains("Missing required parameter"));
2954    }
2955
2956    #[tokio::test]
2957    async fn test_read_file_no_file_store() {
2958        let context = ToolContext::new(SessionId::new());
2959        let result = ReadFileTool
2960            .execute_with_context(json!({"path": "/test.txt"}), &context)
2961            .await;
2962        assert!(expect_tool_error(result).contains("not available"));
2963    }
2964
2965    #[tokio::test]
2966    async fn test_read_file_returns_content_hash() {
2967        let store = Arc::new(MockFileStore::default());
2968        store.add_text_file("/notes.txt", "hello world");
2969        let context = make_context(store);
2970
2971        let result = ReadFileTool
2972            .execute_with_context(json!({"path": "/workspace/notes.txt"}), &context)
2973            .await;
2974        let value = expect_success(result);
2975
2976        assert_eq!(value["path"], "/workspace/notes.txt");
2977        assert_eq!(value["content"], "1|hello world");
2978        assert_eq!(value["total_lines"], 1);
2979        assert_eq!(value["truncated"], false);
2980        assert_eq!(
2981            value["content_hash"].as_str().unwrap(),
2982            file_content_hash("hello world", "text").unwrap()
2983        );
2984    }
2985
2986    #[tokio::test]
2987    async fn test_read_file_offset_limit() {
2988        let store = Arc::new(MockFileStore::default());
2989        let content = (1..=100)
2990            .map(|i| format!("line {}", i))
2991            .collect::<Vec<_>>()
2992            .join("\n");
2993        store.add_text_file("/big.txt", &content);
2994        let context = make_context(store);
2995
2996        // Read lines 10-14 (0-indexed offset=9, limit=5)
2997        let result = ReadFileTool
2998            .execute_with_context(
2999                json!({"path": "/workspace/big.txt", "offset": 9, "limit": 5}),
3000                &context,
3001            )
3002            .await;
3003        let value = expect_success(result);
3004
3005        assert_eq!(value["total_lines"], 100);
3006        assert_eq!(value["truncated"], true);
3007        assert_eq!(value["lines_shown"]["start"], 10);
3008        assert_eq!(value["lines_shown"]["end"], 14);
3009        let content_str = value["content"].as_str().unwrap();
3010        assert!(content_str.starts_with("10|line 10"));
3011        assert!(content_str.ends_with("14|line 14"));
3012    }
3013
3014    #[tokio::test]
3015    async fn test_read_file_default_limit_truncates() {
3016        let store = Arc::new(MockFileStore::default());
3017        let content = (1..=2500)
3018            .map(|i| format!("line {}", i))
3019            .collect::<Vec<_>>()
3020            .join("\n");
3021        store.add_text_file("/huge.txt", &content);
3022        let context = make_context(store);
3023
3024        let result = ReadFileTool
3025            .execute_with_context(json!({"path": "/workspace/huge.txt"}), &context)
3026            .await;
3027        let value = expect_success(result);
3028
3029        assert_eq!(value["total_lines"], 2500);
3030        assert_eq!(value["truncated"], true);
3031        assert_eq!(value["lines_shown"]["start"], 1);
3032        assert_eq!(value["lines_shown"]["end"], 2000);
3033    }
3034
3035    // ============================================================================
3036    // EVE-339 — Reading-tool truncation envelope conformance
3037    // ============================================================================
3038
3039    #[tokio::test]
3040    async fn test_read_file_truncation_envelope_when_not_truncated() {
3041        let store = Arc::new(MockFileStore::default());
3042        store.add_text_file("/notes.txt", "hello world");
3043        let context = make_context(store);
3044
3045        let result = ReadFileTool
3046            .execute_with_context(json!({"path": "/workspace/notes.txt"}), &context)
3047            .await;
3048        let value = expect_success(result);
3049
3050        crate::truncation_info::assert_conforms("read_file", &value);
3051        assert_eq!(value["truncation"]["truncated"], false);
3052    }
3053
3054    #[tokio::test]
3055    async fn test_read_file_truncation_envelope_with_resume() {
3056        let store = Arc::new(MockFileStore::default());
3057        let content = (1..=2500)
3058            .map(|i| format!("line {}", i))
3059            .collect::<Vec<_>>()
3060            .join("\n");
3061        store.add_text_file("/huge.txt", &content);
3062        let context = make_context(store);
3063
3064        let result = ReadFileTool
3065            .execute_with_context(json!({"path": "/workspace/huge.txt"}), &context)
3066            .await;
3067        let value = expect_success(result);
3068
3069        crate::truncation_info::assert_conforms("read_file", &value);
3070        assert_eq!(value["truncation"]["truncated"], true);
3071        assert_eq!(value["truncation"]["reason"], "line_cap");
3072        assert_eq!(value["truncation"]["next_offset"], 2000);
3073        assert!(
3074            value["truncation"]["resume_hint"]
3075                .as_str()
3076                .unwrap()
3077                .contains("offset=2000")
3078        );
3079    }
3080
3081    #[tokio::test]
3082    async fn test_read_file_resume_roundtrip_reaches_end() {
3083        let store = Arc::new(MockFileStore::default());
3084        let content = (1..=2500)
3085            .map(|i| format!("line {}", i))
3086            .collect::<Vec<_>>()
3087            .join("\n");
3088        store.add_text_file("/huge.txt", &content);
3089        let context = make_context(store);
3090
3091        // First page
3092        let first = expect_success(
3093            ReadFileTool
3094                .execute_with_context(json!({"path": "/workspace/huge.txt"}), &context)
3095                .await,
3096        );
3097        let next_offset = first["truncation"]["next_offset"].as_u64().unwrap();
3098
3099        // Resume from next_offset
3100        let second = expect_success(
3101            ReadFileTool
3102                .execute_with_context(
3103                    json!({"path": "/workspace/huge.txt", "offset": next_offset, "limit": 1000}),
3104                    &context,
3105                )
3106                .await,
3107        );
3108
3109        // After resuming we cover the remaining 500 lines and the envelope
3110        // reports `truncated: false` on the final chunk.
3111        assert_eq!(second["truncation"]["truncated"], false);
3112        let shown = &second["lines_shown"];
3113        assert_eq!(shown["start"], 2001);
3114        assert_eq!(shown["end"], 2500);
3115    }
3116
3117    #[tokio::test]
3118    async fn test_list_directory_emits_truncation_envelope() {
3119        let store = Arc::new(MockFileStore::default());
3120        store.add_text_file("/a.txt", "a");
3121        store.add_text_file("/b.txt", "b");
3122        let context = make_context(store);
3123
3124        let result = ListDirectoryTool
3125            .execute_with_context(json!({"path": "/workspace"}), &context)
3126            .await;
3127        let value = expect_success(result);
3128
3129        crate::truncation_info::assert_conforms("list_directory", &value);
3130        assert_eq!(value["truncation"]["truncated"], false);
3131    }
3132
3133    #[tokio::test]
3134    async fn test_list_directory_applies_item_window() {
3135        let store = Arc::new(MockFileStore::default());
3136        store.add_text_file("/a.txt", "a");
3137        store.add_text_file("/b.txt", "b");
3138        store.add_text_file("/c.txt", "c");
3139        let context = make_context(store);
3140
3141        let result = ListDirectoryTool
3142            .execute_with_context(json!({"path": "/workspace", "limit": 2}), &context)
3143            .await;
3144        let value = expect_success(result);
3145
3146        crate::truncation_info::assert_conforms("list_directory", &value);
3147        assert_eq!(value["count"], 2);
3148        assert_eq!(value["total_count"], 3);
3149        assert_eq!(value["truncation"]["truncated"], true);
3150        assert_eq!(value["truncation"]["reason"], "item_cap");
3151        assert_eq!(value["truncation"]["next_offset"], 2);
3152    }
3153
3154    #[tokio::test]
3155    async fn test_grep_files_emits_truncation_envelope() {
3156        let store = Arc::new(MockFileStore::default());
3157        store.add_text_file("/notes.txt", "hello world");
3158        let context = make_context(store);
3159
3160        let result = GrepFilesTool
3161            .execute_with_context(json!({"pattern": "hello"}), &context)
3162            .await;
3163        let value = expect_success(result);
3164
3165        crate::truncation_info::assert_conforms("grep_files", &value);
3166        assert_eq!(value["truncation"]["truncated"], false);
3167    }
3168
3169    #[tokio::test]
3170    async fn test_grep_files_applies_match_window() {
3171        let store = Arc::new(MockFileStore::default());
3172        store.add_text_file("/notes.txt", "hello one\nhello two\nhello three");
3173        let context = make_context(store);
3174
3175        let result = GrepFilesTool
3176            .execute_with_context(json!({"pattern": "hello", "limit": 2}), &context)
3177            .await;
3178        let value = expect_success(result);
3179
3180        crate::truncation_info::assert_conforms("grep_files", &value);
3181        assert_eq!(value["match_count"], 2);
3182        assert_eq!(value["total_matches"], 3);
3183        assert_eq!(value["truncation"]["truncated"], true);
3184        assert_eq!(value["truncation"]["reason"], "line_cap");
3185        assert_eq!(value["truncation"]["next_offset"], 2);
3186    }
3187
3188    #[tokio::test]
3189    async fn test_grep_files_returns_merged_numbered_context() {
3190        let store = Arc::new(MockFileStore::default());
3191        store.add_text_file("/notes.txt", "before\nmatch one\nbetween\nmatch two\nafter");
3192        let context = make_context(store);
3193
3194        let value = expect_success(
3195            GrepFilesTool
3196                .execute_with_context(
3197                    json!({"pattern": "match", "before_context": 1, "after_context": 1}),
3198                    &context,
3199                )
3200                .await,
3201        );
3202
3203        assert!(value.get("matches").is_none());
3204        assert_eq!(value["blocks"].as_array().unwrap().len(), 1);
3205        assert_eq!(value["blocks"][0]["start_line"], 1);
3206        assert_eq!(value["blocks"][0]["end_line"], 5);
3207        assert_eq!(value["blocks"][0]["match_line_numbers"], json!([2, 4]));
3208        assert_eq!(value["blocks"][0]["lines"].as_array().unwrap().len(), 5);
3209    }
3210
3211    #[tokio::test]
3212    async fn test_grep_files_rejects_invalid_context_values() {
3213        let context = make_context(Arc::new(MockFileStore::default()));
3214        for arguments in [
3215            json!({"pattern": "x", "before_context": -1}),
3216            json!({"pattern": "x", "after_context": 21}),
3217            json!({"pattern": "x", "before_context": 1.5}),
3218        ] {
3219            let result = GrepFilesTool
3220                .execute_with_context(arguments, &context)
3221                .await;
3222            assert!(matches!(result, ToolExecutionResult::ToolError(_)));
3223        }
3224    }
3225
3226    #[tokio::test]
3227    async fn test_grep_files_enforces_total_byte_budget() {
3228        let store = Arc::new(MockFileStore::default());
3229        store.add_text_file("/large.txt", &format!("match {}", "x".repeat(70_000)));
3230        let context = make_context(store);
3231        let value = expect_success(
3232            GrepFilesTool
3233                .execute_with_context(json!({"pattern": "match"}), &context)
3234                .await,
3235        );
3236
3237        assert!(
3238            value["matches"][0]["line"].as_str().unwrap().len() <= crate::GREP_MAX_RETURN_BYTES
3239        );
3240        assert_eq!(value["truncation"]["truncated"], true);
3241        assert_eq!(value["truncation"]["reason"], "size_cap");
3242        assert!(value["truncation"]["bytes_total"].as_u64().unwrap() > 64 * 1024);
3243    }
3244
3245    #[tokio::test]
3246    async fn test_write_file_returns_content_hash() {
3247        let store = Arc::new(MockFileStore::default());
3248        let context = make_context(store.clone());
3249
3250        let result = WriteFileTool
3251            .execute_with_context(
3252                json!({"path": "/workspace/new.txt", "content": "hello world"}),
3253                &context,
3254            )
3255            .await;
3256        let value = expect_success(result);
3257
3258        assert_eq!(value["path"], "/workspace/new.txt");
3259        assert_eq!(value["size_bytes"], 11);
3260        assert_eq!(
3261            value["content_hash"].as_str().unwrap(),
3262            file_content_hash("hello world", "text").unwrap()
3263        );
3264        assert_eq!(store.content("/new.txt").unwrap(), "hello world");
3265    }
3266
3267    #[tokio::test]
3268    async fn test_edit_file_single_replace_success() {
3269        let store = Arc::new(MockFileStore::default());
3270        store.add_text_file("/notes.txt", "alpha\nbeta\ngamma\n");
3271        let context = make_context(store.clone());
3272        let expected_hash = read_hash(&context, "/workspace/notes.txt").await;
3273
3274        let result = EditFileTool
3275            .execute_with_context(
3276                json!({
3277                    "path": "/workspace/notes.txt",
3278                    "expected_hash": expected_hash,
3279                    "old_text": "beta",
3280                    "new_text": "delta"
3281                }),
3282                &context,
3283            )
3284            .await;
3285        let value = expect_success(result);
3286
3287        assert_eq!(
3288            store.content("/notes.txt").unwrap(),
3289            "alpha\ndelta\ngamma\n"
3290        );
3291        assert_eq!(value["applied_edits"], 1);
3292        assert_eq!(value["first_changed_line"], 2);
3293        assert!(value["diff"].as_str().unwrap().contains("-beta"));
3294        assert!(value["diff"].as_str().unwrap().contains("+delta"));
3295        assert_ne!(
3296            value["content_hash"].as_str().unwrap(),
3297            value["previous_content_hash"].as_str().unwrap()
3298        );
3299    }
3300
3301    #[tokio::test]
3302    async fn test_edit_file_batch_replace_success() {
3303        let store = Arc::new(MockFileStore::default());
3304        store.add_text_file("/batch.txt", "one\ntwo\nthree\n");
3305        let context = make_context(store.clone());
3306        let expected_hash = read_hash(&context, "/workspace/batch.txt").await;
3307
3308        let result = EditFileTool
3309            .execute_with_context(
3310                json!({
3311                    "path": "/workspace/batch.txt",
3312                    "expected_hash": expected_hash,
3313                    "edits": [
3314                        {"old_text": "one", "new_text": "ONE"},
3315                        {"old_text": "three", "new_text": "THREE"}
3316                    ]
3317                }),
3318                &context,
3319            )
3320            .await;
3321        let value = expect_success(result);
3322
3323        assert_eq!(store.content("/batch.txt").unwrap(), "ONE\ntwo\nTHREE\n");
3324        assert_eq!(value["applied_edits"], 2);
3325        assert_eq!(value["first_changed_line"], 1);
3326    }
3327
3328    #[tokio::test]
3329    async fn test_edit_file_batch_replace_ignores_empty_single_placeholders() {
3330        let store = Arc::new(MockFileStore::default());
3331        store.add_text_file("/batch-placeholders.txt", "one\ntwo\nthree\n");
3332        let context = make_context(store.clone());
3333        let expected_hash = read_hash(&context, "/workspace/batch-placeholders.txt").await;
3334
3335        let result = EditFileTool
3336            .execute_with_context(
3337                json!({
3338                    "path": "/workspace/batch-placeholders.txt",
3339                    "expected_hash": expected_hash,
3340                    "edits": [
3341                        {"old_text": "one", "new_text": "ONE"},
3342                        {"old_text": "three", "new_text": "THREE"}
3343                    ],
3344                    "old_text": "",
3345                    "new_text": ""
3346                }),
3347                &context,
3348            )
3349            .await;
3350        let value = expect_success(result);
3351
3352        assert_eq!(
3353            store.content("/batch-placeholders.txt").unwrap(),
3354            "ONE\ntwo\nTHREE\n"
3355        );
3356        assert_eq!(value["applied_edits"], 2);
3357    }
3358
3359    #[tokio::test]
3360    async fn test_edit_file_allows_delete_replacement() {
3361        let store = Arc::new(MockFileStore::default());
3362        store.add_text_file("/delete.txt", "keep\nremove me\nkeep\n");
3363        let context = make_context(store.clone());
3364        let expected_hash = read_hash(&context, "/workspace/delete.txt").await;
3365
3366        let result = EditFileTool
3367            .execute_with_context(
3368                json!({
3369                    "path": "/workspace/delete.txt",
3370                    "expected_hash": expected_hash,
3371                    "old_text": "remove me\n",
3372                    "new_text": ""
3373                }),
3374                &context,
3375            )
3376            .await;
3377
3378        expect_success(result);
3379        assert_eq!(store.content("/delete.txt").unwrap(), "keep\nkeep\n");
3380    }
3381
3382    #[tokio::test]
3383    async fn test_edit_file_preserves_bom_and_crlf() {
3384        let store = Arc::new(MockFileStore::default());
3385        store.add_text_file("/windows.txt", "\u{feff}alpha\r\nbeta\r\n");
3386        let context = make_context(store.clone());
3387        let expected_hash = read_hash(&context, "/workspace/windows.txt").await;
3388
3389        let result = EditFileTool
3390            .execute_with_context(
3391                json!({
3392                    "path": "/workspace/windows.txt",
3393                    "expected_hash": expected_hash,
3394                    "old_text": "beta\n",
3395                    "new_text": "gamma\n"
3396                }),
3397                &context,
3398            )
3399            .await;
3400
3401        expect_success(result);
3402        assert_eq!(
3403            store.content("/windows.txt").unwrap(),
3404            "\u{feff}alpha\r\ngamma\r\n"
3405        );
3406    }
3407
3408    #[tokio::test]
3409    async fn test_edit_file_preserves_cr_line_endings() {
3410        let store = Arc::new(MockFileStore::default());
3411        store.add_text_file("/classic-mac.txt", "alpha\rbeta\r");
3412        let context = make_context(store.clone());
3413        let expected_hash = read_hash(&context, "/workspace/classic-mac.txt").await;
3414
3415        let result = EditFileTool
3416            .execute_with_context(
3417                json!({
3418                    "path": "/workspace/classic-mac.txt",
3419                    "expected_hash": expected_hash,
3420                    "old_text": "beta\n",
3421                    "new_text": "gamma\n"
3422                }),
3423                &context,
3424            )
3425            .await;
3426
3427        expect_success(result);
3428        assert_eq!(store.content("/classic-mac.txt").unwrap(), "alpha\rgamma\r");
3429    }
3430
3431    #[tokio::test]
3432    async fn test_edit_file_rejects_hash_mismatch() {
3433        let store = Arc::new(MockFileStore::default());
3434        store.add_text_file("/stale.txt", "hello");
3435        let context = make_context(store);
3436
3437        let result = EditFileTool
3438            .execute_with_context(
3439                json!({
3440                    "path": "/workspace/stale.txt",
3441                    "expected_hash": "sha256:stale",
3442                    "old_text": "hello",
3443                    "new_text": "goodbye"
3444                }),
3445                &context,
3446            )
3447            .await;
3448
3449        assert!(expect_tool_error(result).contains("changed since the last read"));
3450    }
3451
3452    #[tokio::test]
3453    async fn test_edit_file_rejects_binary_file() {
3454        let store = Arc::new(MockFileStore::default());
3455        store.add_base64_file("/image.png", "aGVsbG8=");
3456        let context = make_context(store.clone());
3457        let expected_hash = read_hash(&context, "/workspace/image.png").await;
3458
3459        let result = EditFileTool
3460            .execute_with_context(
3461                json!({
3462                    "path": "/workspace/image.png",
3463                    "expected_hash": expected_hash,
3464                    "old_text": "hello",
3465                    "new_text": "goodbye"
3466                }),
3467                &context,
3468            )
3469            .await;
3470
3471        assert!(expect_tool_error(result).contains("only supports text files"));
3472    }
3473
3474    #[tokio::test]
3475    async fn test_read_file_non_image_binary_omits_base64_content() {
3476        let store = Arc::new(MockFileStore::default());
3477        store.add_base64_file("/archive.zip", "UEsDBAoAAAAAAA==");
3478        let context = make_context(store);
3479
3480        let result = ReadFileTool
3481            .execute_with_context(json!({"path": "/workspace/archive.zip"}), &context)
3482            .await;
3483        let value = expect_success(result);
3484
3485        assert_eq!(value["content_type"], "binary");
3486        assert_eq!(value["encoding"], "base64");
3487        assert_eq!(value["truncation"]["truncated"], false);
3488        assert_eq!(value["truncation"]["bytes_returned"], 0);
3489        assert!(value.get("content").is_none());
3490        assert!(value.get("content_hash").is_some());
3491    }
3492
3493    #[tokio::test]
3494    async fn test_edit_file_rejects_directory() {
3495        let store = Arc::new(MockFileStore::default());
3496        store.add_directory("/docs");
3497        let context = make_context(store);
3498
3499        let result = EditFileTool
3500            .execute_with_context(
3501                json!({
3502                    "path": "/workspace/docs",
3503                    "expected_hash": "sha256:anything",
3504                    "old_text": "hello",
3505                    "new_text": "goodbye"
3506                }),
3507                &context,
3508            )
3509            .await;
3510
3511        assert!(expect_tool_error(result).contains("is a directory"));
3512    }
3513
3514    #[tokio::test]
3515    async fn test_edit_file_rejects_missing_match() {
3516        let store = Arc::new(MockFileStore::default());
3517        store.add_text_file("/missing.txt", "hello");
3518        let context = make_context(store.clone());
3519        let expected_hash = read_hash(&context, "/workspace/missing.txt").await;
3520
3521        let result = EditFileTool
3522            .execute_with_context(
3523                json!({
3524                    "path": "/workspace/missing.txt",
3525                    "expected_hash": expected_hash,
3526                    "old_text": "absent",
3527                    "new_text": "present"
3528                }),
3529                &context,
3530            )
3531            .await;
3532
3533        assert!(expect_tool_error(result).contains("Could not find an exact match"));
3534    }
3535
3536    #[tokio::test]
3537    async fn test_edit_file_rejects_ambiguous_match() {
3538        let store = Arc::new(MockFileStore::default());
3539        store.add_text_file("/ambiguous.txt", "hello\nhello\n");
3540        let context = make_context(store.clone());
3541        let expected_hash = read_hash(&context, "/workspace/ambiguous.txt").await;
3542
3543        let result = EditFileTool
3544            .execute_with_context(
3545                json!({
3546                    "path": "/workspace/ambiguous.txt",
3547                    "expected_hash": expected_hash,
3548                    "old_text": "hello",
3549                    "new_text": "goodbye"
3550                }),
3551                &context,
3552            )
3553            .await;
3554
3555        assert!(expect_tool_error(result).contains("matched multiple locations"));
3556    }
3557
3558    #[tokio::test]
3559    async fn test_edit_file_rejects_overlapping_batch_edits() {
3560        let store = Arc::new(MockFileStore::default());
3561        store.add_text_file("/overlap.txt", "abcdef");
3562        let context = make_context(store.clone());
3563        let expected_hash = read_hash(&context, "/workspace/overlap.txt").await;
3564
3565        let result = EditFileTool
3566            .execute_with_context(
3567                json!({
3568                    "path": "/workspace/overlap.txt",
3569                    "expected_hash": expected_hash,
3570                    "edits": [
3571                        {"old_text": "abcd", "new_text": "WXYZ"},
3572                        {"old_text": "cdef", "new_text": "1234"}
3573                    ]
3574                }),
3575                &context,
3576            )
3577            .await;
3578
3579        assert!(expect_tool_error(result).contains("Edits overlap"));
3580    }
3581
3582    #[tokio::test]
3583    async fn test_edit_file_rejects_missing_expected_hash() {
3584        let store = Arc::new(MockFileStore::default());
3585        store.add_text_file("/hashless.txt", "hello");
3586        let context = make_context(store);
3587
3588        let result = EditFileTool
3589            .execute_with_context(
3590                json!({
3591                    "path": "/workspace/hashless.txt",
3592                    "old_text": "hello",
3593                    "new_text": "goodbye"
3594                }),
3595                &context,
3596            )
3597            .await;
3598
3599        assert!(expect_tool_error(result).contains("Missing required parameter: expected_hash"));
3600    }
3601
3602    #[tokio::test]
3603    async fn test_edit_file_rejects_readonly_target() {
3604        let store = Arc::new(MockFileStore::default());
3605        store.add_readonly_text_file("/readonly.txt", "hello");
3606        let context = make_context(store.clone());
3607        let expected_hash = read_hash(&context, "/workspace/readonly.txt").await;
3608
3609        let result = EditFileTool
3610            .execute_with_context(
3611                json!({
3612                    "path": "/workspace/readonly.txt",
3613                    "expected_hash": expected_hash,
3614                    "old_text": "hello",
3615                    "new_text": "goodbye"
3616                }),
3617                &context,
3618            )
3619            .await;
3620
3621        assert!(expect_tool_error(result).contains("readonly"));
3622    }
3623
3624    #[tokio::test]
3625    async fn test_edit_file_detects_concurrent_change_during_write() {
3626        let store = Arc::new(MockFileStore::default());
3627        store.add_text_file("/race.txt", "hello");
3628        store.inject_conditional_write_change("/race.txt", StoredFile::text("hola"));
3629        let context = make_context(store.clone());
3630        let expected_hash = read_hash(&context, "/workspace/race.txt").await;
3631
3632        let result = EditFileTool
3633            .execute_with_context(
3634                json!({
3635                    "path": "/workspace/race.txt",
3636                    "expected_hash": expected_hash,
3637                    "old_text": "hello",
3638                    "new_text": "goodbye"
3639                }),
3640                &context,
3641            )
3642            .await;
3643
3644        assert!(expect_tool_error(result).contains("changed since the last read"));
3645        assert_eq!(store.content("/race.txt").unwrap(), "hola");
3646    }
3647
3648    #[tokio::test]
3649    async fn test_edit_file_truncates_large_diffs() {
3650        let store = Arc::new(MockFileStore::default());
3651        let original = format!("{}\n", "a".repeat(MAX_EDIT_DIFF_CHARS + 2000));
3652        let replacement = format!("{}\n", "b".repeat(MAX_EDIT_DIFF_CHARS + 2000));
3653        store.add_text_file("/large.txt", &original);
3654        let context = make_context(store.clone());
3655        let expected_hash = read_hash(&context, "/workspace/large.txt").await;
3656
3657        let result = EditFileTool
3658            .execute_with_context(
3659                json!({
3660                    "path": "/workspace/large.txt",
3661                    "expected_hash": expected_hash,
3662                    "old_text": original,
3663                    "new_text": replacement
3664                }),
3665                &context,
3666            )
3667            .await;
3668        let value = expect_success(result);
3669
3670        assert_eq!(value["diff_truncated"], true);
3671        assert!(
3672            value["diff"]
3673                .as_str()
3674                .unwrap()
3675                .contains("diff truncated after")
3676        );
3677    }
3678
3679    #[test]
3680    fn test_image_media_type_png() {
3681        assert_eq!(
3682            image_media_type("/workspace/screenshot.png"),
3683            Some("image/png")
3684        );
3685    }
3686
3687    #[test]
3688    fn test_image_media_type_jpeg() {
3689        assert_eq!(image_media_type("/workspace/photo.jpg"), Some("image/jpeg"));
3690        assert_eq!(
3691            image_media_type("/workspace/photo.jpeg"),
3692            Some("image/jpeg")
3693        );
3694    }
3695
3696    #[test]
3697    fn test_image_media_type_gif() {
3698        assert_eq!(image_media_type("/data/anim.gif"), Some("image/gif"));
3699    }
3700
3701    #[test]
3702    fn test_image_media_type_webp() {
3703        assert_eq!(image_media_type("/images/art.webp"), Some("image/webp"));
3704    }
3705
3706    #[test]
3707    fn test_image_media_type_case_insensitive() {
3708        assert_eq!(image_media_type("/workspace/PHOTO.PNG"), Some("image/png"));
3709        assert_eq!(image_media_type("/workspace/image.JPG"), Some("image/jpeg"));
3710    }
3711
3712    #[test]
3713    fn test_image_media_type_not_image() {
3714        assert_eq!(image_media_type("/workspace/readme.txt"), None);
3715        assert_eq!(image_media_type("/workspace/data.json"), None);
3716        assert_eq!(image_media_type("/workspace/script.py"), None);
3717    }
3718
3719    // EVE-249: Content-type detection tests
3720    #[test]
3721    fn test_content_type_log_files() {
3722        assert_eq!(content_type_from_extension("/app.log"), ContentType::Log);
3723        assert_eq!(content_type_from_extension("/build.out"), ContentType::Log);
3724        assert_eq!(content_type_from_extension("/debug.LOG"), ContentType::Log);
3725    }
3726
3727    #[test]
3728    fn test_content_type_csv_files() {
3729        assert_eq!(content_type_from_extension("/data.csv"), ContentType::Csv);
3730        assert_eq!(content_type_from_extension("/export.tsv"), ContentType::Csv);
3731        assert_eq!(content_type_from_extension("/data.CSV"), ContentType::Csv);
3732    }
3733
3734    #[test]
3735    fn test_content_type_binary_files() {
3736        assert_eq!(
3737            content_type_from_extension("/app.wasm"),
3738            ContentType::Binary
3739        );
3740        assert_eq!(content_type_from_extension("/lib.so"), ContentType::Binary);
3741        assert_eq!(
3742            content_type_from_extension("/archive.zip"),
3743            ContentType::Binary
3744        );
3745        assert_eq!(
3746            content_type_from_extension("/font.woff2"),
3747            ContentType::Binary
3748        );
3749    }
3750
3751    #[test]
3752    fn test_content_type_minified_files() {
3753        assert_eq!(
3754            content_type_from_extension("/bundle.min.js"),
3755            ContentType::Minified
3756        );
3757        assert_eq!(
3758            content_type_from_extension("/styles.min.css"),
3759            ContentType::Minified
3760        );
3761    }
3762
3763    #[test]
3764    fn test_content_type_text_files() {
3765        assert_eq!(content_type_from_extension("/main.rs"), ContentType::Text);
3766        assert_eq!(content_type_from_extension("/index.ts"), ContentType::Text);
3767        assert_eq!(content_type_from_extension("/README.md"), ContentType::Text);
3768        assert_eq!(
3769            content_type_from_extension("/config.json"),
3770            ContentType::Text
3771        );
3772    }
3773
3774    #[test]
3775    fn test_content_type_minified_before_generic_js() {
3776        // .min.js should be Minified, not Text
3777        assert_eq!(
3778            content_type_from_extension("/bundle.min.js"),
3779            ContentType::Minified
3780        );
3781        // Plain .js should be Text
3782        assert_eq!(content_type_from_extension("/app.js"), ContentType::Text);
3783    }
3784
3785    #[test]
3786    fn test_effective_read_defaults_explicit_wins() {
3787        // When user provides both offset and limit, don't override
3788        let (_, mode) = effective_read_defaults("/app.log", true, true);
3789        assert_eq!(mode, ReadMode::FromOffset);
3790    }
3791
3792    #[test]
3793    fn test_effective_read_defaults_log_tail() {
3794        let (limit, mode) = effective_read_defaults("/app.log", false, false);
3795        assert_eq!(limit, 500);
3796        assert_eq!(mode, ReadMode::FromEnd);
3797    }
3798
3799    #[test]
3800    fn test_effective_read_defaults_csv() {
3801        let (limit, mode) = effective_read_defaults("/data.csv", false, false);
3802        assert_eq!(limit, 100);
3803        assert_eq!(mode, ReadMode::FromOffset);
3804    }
3805
3806    #[test]
3807    fn test_effective_read_defaults_binary() {
3808        let (_, mode) = effective_read_defaults("/app.wasm", false, false);
3809        assert_eq!(mode, ReadMode::MetadataOnly);
3810    }
3811
3812    #[test]
3813    fn localized_name_differs_from_default() {
3814        let cap = FileSystemCapability;
3815        assert_ne!(cap.localized_name(Some("uk")), cap.name());
3816    }
3817
3818    #[test]
3819    fn host_backed_tool_schemas_contain_no_workspace_guidance() {
3820        let presentation = FilePathPresentation::from_file_store(Some(
3821            &MockFileStore::with_display_root("/repo") as &dyn SessionFileSystem,
3822        ));
3823        for (tool_name, schema) in filesystem_tool_schemas_with_presentation(&presentation) {
3824            assert!(
3825                !schema_contains_workspace(&schema),
3826                "tool '{tool_name}' schema must not advertise /workspace for host-backed roots"
3827            );
3828            if tool_name == "list_directory" {
3829                assert_eq!(schema["properties"]["path"]["default"], "/repo");
3830            }
3831        }
3832    }
3833
3834    #[test]
3835    fn vfs_tool_schemas_advertise_workspace_identity() {
3836        let presentation = FilePathPresentation::vfs();
3837        let schemas = filesystem_tool_schemas_with_presentation(&presentation);
3838        let path_tools = ["read_file", "write_file", "edit_file", "list_directory"];
3839        for tool_name in path_tools {
3840            let schema = schemas
3841                .iter()
3842                .find(|(name, _)| name == tool_name)
3843                .map(|(_, schema)| schema)
3844                .expect("schema present");
3845            assert!(
3846                schema_contains_workspace(schema),
3847                "tool '{tool_name}' schema should mention /workspace for VFS sessions"
3848            );
3849        }
3850        assert_eq!(
3851            schemas
3852                .iter()
3853                .find(|(name, _)| name == "list_directory")
3854                .unwrap()
3855                .1["properties"]["path"]["default"],
3856            "/workspace"
3857        );
3858    }
3859
3860    #[tokio::test]
3861    async fn assembled_prompt_uses_host_root_without_workspace_guidance() {
3862        use crate::AgentCapabilityConfig;
3863        use crate::capabilities::{CapabilityRegistry, collect_capabilities_with_configs};
3864
3865        let store = Arc::new(MockFileStore::with_display_root("/repo"));
3866        let ctx = SystemPromptContext {
3867            session_id: SessionId::new(),
3868            locale: None,
3869            file_store: Some(store),
3870            model: None,
3871        };
3872        let registry = CapabilityRegistry::with_builtins();
3873        let collected = collect_capabilities_with_configs(
3874            &[AgentCapabilityConfig::new(
3875                SESSION_FILE_SYSTEM_CAPABILITY_ID,
3876            )],
3877            &registry,
3878            &ctx,
3879        )
3880        .await;
3881
3882        let prompt = collected.system_prompt_prefix().expect("system prompt");
3883        assert!(prompt.contains("Workspace root: `/repo`"));
3884        assert!(!prompt.contains("/workspace"));
3885    }
3886
3887    #[tokio::test]
3888    async fn tool_definition_hook_applies_host_root_to_eager_and_deferred_schemas() {
3889        use crate::capabilities::tool_search::ToolSearchCapability;
3890        use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolDefinition};
3891
3892        let store = Arc::new(MockFileStore::with_display_root("/repo"));
3893        let ctx = SystemPromptContext {
3894            session_id: SessionId::new(),
3895            locale: None,
3896            file_store: Some(store),
3897            model: None,
3898        };
3899        let cap = FileSystemCapability;
3900        let hooks = cap.tool_definition_hooks_with_context(&ctx, &json!({}));
3901        assert_eq!(hooks.len(), 1);
3902
3903        let read_file = ToolDefinition::Builtin(BuiltinTool {
3904            name: "read_file".to_string(),
3905            display_name: None,
3906            description: "Read file".to_string(),
3907            parameters: ReadFileTool.parameters_schema(),
3908            policy: Default::default(),
3909            category: None,
3910            deferrable: DeferrablePolicy::Automatic,
3911            hints: Default::default(),
3912            full_parameters: None,
3913        });
3914        let eager = hooks[0].transform(vec![read_file.clone()]);
3915        assert!(!schema_contains_workspace(eager[0].full_parameters()));
3916
3917        let defer_cap = ToolSearchCapability::with_threshold(1);
3918        let defer_hooks =
3919            defer_cap.tool_definition_hooks_with_context(&ctx, &json!({ "threshold": 1 }));
3920        let deferred = defer_hooks[0].transform(hooks[0].transform(vec![
3921            read_file,
3922            ToolDefinition::Builtin(BuiltinTool {
3923                name: "other_tool".to_string(),
3924                display_name: None,
3925                description: "Other".to_string(),
3926                parameters: json!({"type": "object"}),
3927                policy: Default::default(),
3928                category: None,
3929                deferrable: DeferrablePolicy::Automatic,
3930                hints: Default::default(),
3931                full_parameters: None,
3932            }),
3933        ]));
3934        let read = deferred
3935            .iter()
3936            .find(|tool| tool.name() == "read_file")
3937            .expect("read_file present");
3938        assert!(!schema_contains_workspace(read.full_parameters()));
3939    }
3940
3941    #[tokio::test]
3942    async fn list_directory_without_path_uses_workspace_display_root() {
3943        // File tools run behind MountFs; the mounted primary presents the stable
3944        // host-agnostic /workspace root rather than the backend's host path.
3945        let store = Arc::new(MockFileStore::with_display_root("/repo"));
3946        store.add_text_file("/notes.txt", "hello");
3947        let context = make_context(store);
3948
3949        let result = ListDirectoryTool
3950            .execute_with_context(json!({}), &context)
3951            .await;
3952        let value = expect_success(result);
3953
3954        assert_eq!(value["path"], "/workspace");
3955    }
3956}