//! Tool registry, validation, built-in file effects, and the durable //! outcome boundary between successful effects and recorded session frames. //! //! (`SMH-SPEC-SPEC0001`, Tools). use crate::session::{Entry, EntryContentRef, Session}; use crate::store::SessionWriter; use smith::config::Config; use smith::error::{Result, SmithError}; use smith::id::ToolCallId; use smith::tool::{CancelHandle, ToolMetadata}; use std::collections::HashMap; use std::fs; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; const HARD_OUTPUT_CAP: usize = 256 * 1024; const DEFAULT_OUTPUT_CAP: usize = 64 * 1024; const MAX_LIST_ENTRIES: usize = 1000; const MAX_GREP_MATCHES: usize = 200; const MAX_EDIT_FILE_BYTES: u64 = 8 * 1024 * 1024; /// Registry of available tools. #[derive(Clone, Debug, Default)] pub struct ToolRegistry { /// Tools by name. tools: HashMap, /// Name-ordered snapshot handed to every provider request; rebuilt on /// registration, shared afterwards. snapshot: Arc<[ToolMetadata]>, } impl ToolRegistry { /// An empty registry. #[must_use] pub fn new() -> Self { Self::default() } /// Register or replace a tool definition. pub fn register(&mut self, meta: ToolMetadata) { self.tools.insert(meta.name.clone(), meta); let mut ordered: Vec = self.tools.values().cloned().collect(); ordered.sort_by(|left, right| left.name.cmp(&right.name)); self.snapshot = ordered.into(); } /// The registered tool, when present. #[must_use] pub fn get(&self, name: &str) -> Option<&ToolMetadata> { self.tools.get(name) } /// The name-ordered tool definitions, for provider requests. #[must_use] pub fn snapshot(&self) -> Arc<[ToolMetadata]> { Arc::clone(&self.snapshot) } /// A registry with the seven built-in coding tools and real schemas. #[must_use] pub fn with_builtins() -> Self { let mut reg = Self::new(); for meta in builtin_tools() { reg.register(meta); } reg } } /// Built-in tool definitions with schemas used for argument validation. #[must_use] pub fn builtin_tools() -> Vec { let object = |required: &[&str], props: serde_json::Value| { serde_json::json!({ "type": "object", "required": required, "properties": props, }) }; vec![ ToolMetadata::new("read", "Read a bounded line window from a file.").with_schema(object( &["path"], serde_json::json!({ "path": {"type": "string"}, "offset": {"type": "integer"}, "limit": {"type": "integer"}, "max_bytes": {"type": "integer"}, }), )), ToolMetadata::new("write", "Atomically replace a file, creating parents.").with_schema( object( &["path", "content"], serde_json::json!({ "path": {"type": "string"}, "content": {"type": "string"}, }), ), ), ToolMetadata::new("edit", "Apply one exact, unambiguous replacement.").with_schema(object( &["path", "old_string", "new_string"], serde_json::json!({ "path": {"type": "string"}, "old_string": {"type": "string"}, "new_string": {"type": "string"}, }), )), ToolMetadata::new("ls", "List a directory deterministically.") .with_schema(object(&[], serde_json::json!({"path": {"type": "string"}}))), ToolMetadata::new("find", "Find directory entries matching a substring.").with_schema( object( &["pattern"], serde_json::json!({ "path": {"type": "string"}, "pattern": {"type": "string"}, }), ), ), ToolMetadata::new("grep", "Return bounded matching lines from a file.").with_schema( object( &["path", "query"], serde_json::json!({ "path": {"type": "string"}, "query": {"type": "string"}, }), ), ), ToolMetadata::new("bash", "Run a bounded cancellable shell command.").with_schema(object( &["command"], serde_json::json!({ "command": {"type": "string"}, "timeout_ms": {"type": "integer"}, }), )), ] } /// Bundles the registry, the live session, and the durable writer so a /// successful mutation can never be reported while its recording failed. pub struct ToolSession { registry: ToolRegistry, /// Live session state; entries are appended as they are recorded. pub session: Session, writer: Option, /// All file effects resolve relative to this directory. pub working_dir: PathBuf, config: Config, cancel: CancelHandle, /// What `read` and `write` last observed per file, used to reject edits /// whose input describes an older version of the file. observed: HashMap, } /// Cheap identity of a file version: size plus modification time. #[derive(Clone, Copy, PartialEq, Eq, Debug)] struct FileVersion { len: u64, modified_ms: i64, } impl FileVersion { fn of(path: &Path) -> Option { let meta = fs::metadata(path).ok()?; let modified_ms = meta .modified() .ok()? .duration_since(std::time::UNIX_EPOCH) .ok()? .as_millis(); Some(Self { len: meta.len(), modified_ms: i64::try_from(modified_ms).unwrap_or(i64::MAX), }) } } impl ToolSession { /// A tool session over built-in tools, no persistence. pub fn new(working_dir: impl Into) -> Self { Self { registry: ToolRegistry::with_builtins(), session: Session::new(), writer: None, working_dir: working_dir.into(), config: Config::default_valid(), cancel: CancelHandle::new(), observed: HashMap::new(), } } /// Use `config` for effect timeouts and frame limits. #[must_use] pub fn with_config(mut self, config: Config) -> Self { self.config = config; self } /// The registered tool definitions, for provider requests. #[must_use] pub fn registered_tools(&self) -> Arc<[ToolMetadata]> { self.registry.snapshot() } /// A handle that aborts in-flight and subsequent effects of this session. #[must_use] pub fn cancel_handle(&self) -> CancelHandle { self.cancel.clone() } pub(crate) fn set_cancel_handle(&mut self, cancel: CancelHandle) { self.cancel = cancel; } /// Continue an existing session, appending every recorded entry to its /// durable writer. pub fn with_session( working_dir: impl Into, session: Session, writer: SessionWriter, ) -> Self { Self { session, writer: Some(writer), ..Self::new(working_dir) } } /// Register or replace a tool definition. pub fn register(&mut self, meta: ToolMetadata) { self.registry.register(meta); } /// Validate arguments against the registered schema (required fields and /// declared property types) before any effect can run. /// /// # Errors /// /// Returns [`SmithError::Tool`] for unknown tools, non-object input, /// missing required fields, or mismatched field types. pub fn validate(&self, name: &str, input: &serde_json::Value) -> Result<()> { let meta = self.registry.get(name).ok_or_else(|| { tool_err( "TOOL_NOT_REGISTERED", format!("tool '{name}' not registered"), ) })?; let obj = input .as_object() .ok_or_else(|| tool_err("INVALID_INPUT", "input must be a JSON object"))?; let schema = &meta.input_schema; for req in schema .get("required") .and_then(|v| v.as_array()) .into_iter() .flatten() { let key = req.as_str().unwrap_or_default(); if !obj.contains_key(key) { return Err(tool_err( "VALIDATION", format!("missing required field '{key}' for tool '{name}'"), )); } } if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) { for (key, value) in obj { if let Some(expected) = props .get(key) .and_then(|p| p.get("type")) .and_then(|t| t.as_str()) && !json_type_matches(expected, value) { { return Err(tool_err( "VALIDATION", format!("field '{key}' for tool '{name}' must be of type {expected}"), )); } } } } Ok(()) } /// Validate, execute, and durably record one tool call. /// /// Recording order is call entry, effect, result entry, so a recorded /// call always precedes the effect it describes. A cancelled session /// refuses to start an effect. If the effect runs but its result cannot /// be recorded, the error states whether host state was already mutated, /// instead of presenting an unrecorded success. /// /// # Errors /// /// Returns [`SmithError::Cancelled`] when the session was cancelled, /// [`SmithError::RecordFailed`] when a completed effect could not be /// recorded, or the effect's own error. pub fn invoke(&mut self, name: &str, input: &serde_json::Value) -> Result { self.invoke_recorded( ToolCallId::new(), name, input, input, str::to_string, EffectFailure::ReturnError, ) } /// Execute trusted local arguments while recording only their redacted /// representation. /// /// `recorded_input` crosses persistence and provider boundaries; /// `effect_input` exists only for the duration of the authorized local /// effect. `redact_output` is applied before either the outcome or its /// durable result can escape that boundary. Executed tool failures become /// recorded outcomes so the model can observe and recover from them. pub(crate) fn invoke_local( &mut self, call_id: ToolCallId, name: &str, recorded_input: &serde_json::Value, effect_input: &serde_json::Value, redact_output: impl Fn(&str) -> String, ) -> Result { self.invoke_recorded( call_id, name, recorded_input, effect_input, redact_output, EffectFailure::ReturnOutcome, ) } fn invoke_recorded( &mut self, call_id: ToolCallId, name: &str, recorded_input: &serde_json::Value, effect_input: &serde_json::Value, redact_output: impl Fn(&str) -> String, failure: EffectFailure, ) -> Result { self.validate(name, recorded_input)?; if self.cancel.is_cancelled() && failure == EffectFailure::ReturnError { return Err(SmithError::Cancelled); } self.record(EntryContentRef::ToolCall { name, input: recorded_input, call_id, })?; let effect = if self.cancel.is_cancelled() { Err(SmithError::Cancelled) } else { execute_effect( name, effect_input, &self.working_dir, &self.config, &self.cancel, &mut self.observed, ) }; let ok = effect.is_ok(); let output = match &effect { Ok(text) => redact_output(text), Err(err) => redact_output(&err.to_string()), }; self.record(EntryContentRef::ToolResult { ok, output: &output, call_id, }) .map_err(|err| SmithError::RecordFailed { tool: name.to_string(), mutation_applied: ok && mutates_host(name), message: err.to_string(), })?; let outcome = ToolOutcome { call_id, name: name.to_string(), ok, output, timestamp_ms: now_ms(), }; match effect { Ok(_) => Ok(outcome), Err(_) if failure == EffectFailure::ReturnOutcome => Ok(outcome), Err(error) => Err(error), } } /// Append `content` after the selected entry and durably record it /// first; a failed write leaves the session unchanged. pub(crate) fn record<'a>(&mut self, content: impl Into>) -> Result { let writer = self.writer.as_mut(); self.session .append_with(content, |entry, bytes| { writer.map_or(Ok(()), |writer| writer.append_entry(entry, bytes)) }) .copied() } } #[derive(Clone, Copy, PartialEq, Eq)] enum EffectFailure { ReturnError, ReturnOutcome, } /// A recorded tool outcome returned to the caller. #[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)] pub struct ToolOutcome { /// Tool call ID. pub call_id: ToolCallId, /// Tool name. pub name: String, /// Whether the effect succeeded. pub ok: bool, /// Recorded output or error message. pub output: String, /// Timestamp of outcome. pub timestamp_ms: i64, } fn tool_err(code: &str, message: impl Into) -> SmithError { SmithError::Tool { code: code.to_string(), message: message.into(), } } fn io_tool_err(code: &'static str) -> impl Fn(std::io::Error) -> SmithError { move |e| tool_err(code, e.to_string()) } fn json_type_matches(expected: &str, value: &serde_json::Value) -> bool { match expected { "string" => value.is_string(), "number" => value.is_number(), "integer" => value.is_i64() || value.is_u64(), "boolean" => value.is_boolean(), "array" => value.is_array(), "object" => value.is_object(), "null" => value.is_null(), _ => true, } } fn resolve_path(input_path: &str, working_dir: &Path) -> PathBuf { let p = Path::new(input_path); if p.is_absolute() { p.to_path_buf() } else { working_dir.join(p) } } /// Execute a built-in file effect. All outputs are bounded and deterministic; /// filesystem errors are surfaced, never converted to empty success. /// Tools whose successful effect changes host state. fn mutates_host(name: &str) -> bool { matches!(name, "write" | "edit" | "bash") } fn execute_effect( name: &str, input: &serde_json::Value, working_dir: &Path, config: &Config, cancel: &CancelHandle, observed: &mut HashMap, ) -> std::result::Result { let path_str = input.get("path").and_then(|v| v.as_str()).unwrap_or(""); match name { "bash" => run_bash(input, working_dir, config.tool_timeout_ms, cancel), "read" => read_effect(input, resolve_path(path_str, working_dir), observed), "write" => write_effect(input, resolve_path(path_str, working_dir), observed), "edit" => edit_effect(input, resolve_path(path_str, working_dir), observed), "ls" => { let dir = directory_or(path_str, working_dir); if !dir.is_dir() { return Err(tool_err( "LS_ERROR", format!("{} is not a directory", dir.display()), )); } let names = walk_relative(&dir, Some(1), config, cancel, "LS_ERROR")?; Ok(bounded_join(names, MAX_LIST_ENTRIES)) } "find" => { let pattern = input .get("pattern") .and_then(|v| v.as_str()) .unwrap_or_default(); let root = directory_or(path_str, working_dir); if !root.is_dir() { return Err(tool_err( "FIND_ERROR", format!("{} is not a directory", root.display()), )); } let names = walk_relative(&root, None, config, cancel, "FIND_ERROR")? .into_iter() .filter(|name| pattern.is_empty() || name.contains(pattern)) .collect(); Ok(bounded_join(names, MAX_LIST_ENTRIES)) } "grep" => { let query = input .get("query") .and_then(|v| v.as_str()) .unwrap_or_default(); let target = directory_or(path_str, working_dir); if target.is_dir() { grep_tree(&target, query, config, cancel) } else { grep_bounded(&target, query) } } _ => Err(tool_err( "TOOL_NOT_IMPLEMENTED", format!("tool '{name}' has no built-in effect"), )), } } /// Read a bounded window and remember the version that was shown. fn read_effect( input: &serde_json::Value, path: PathBuf, observed: &mut HashMap, ) -> std::result::Result { let count = |key: &str| { input .get(key) .and_then(serde_json::Value::as_u64) .map(|value| usize::try_from(value).unwrap_or(usize::MAX)) }; let text = read_bounded( &path, count("offset").unwrap_or(0), count("limit").unwrap_or(usize::MAX), count("max_bytes").map_or(DEFAULT_OUTPUT_CAP, |bytes| bytes.min(HARD_OUTPUT_CAP)), )?; remember(path, observed); Ok(text) } /// Replace a file atomically and remember the version just written. fn write_effect( input: &serde_json::Value, path: PathBuf, observed: &mut HashMap, ) -> std::result::Result { let content = input .get("content") .and_then(|value| value.as_str()) .ok_or_else(|| tool_err("VALIDATION", "write requires string 'content'"))?; write_atomic(&path, content.as_bytes())?; remember(path, observed); Ok(format!("written {} bytes", content.len())) } /// Apply an exact replacement, refusing input that describes an older /// version of the file. /// /// An edit describes a file the caller has seen. If the file moved on since /// then, applying the replacement would silently discard the newer content. fn edit_effect( input: &serde_json::Value, path: PathBuf, observed: &mut HashMap, ) -> std::result::Result { let text = |key: &str| input.get(key).and_then(|v| v.as_str()).unwrap_or_default(); if let Some(seen) = observed.get(&path) && FileVersion::of(&path) != Some(*seen) { return Err(tool_err( "STALE_INPUT", format!( "{} changed since it was last read; read it again before editing", path.display() ), )); } let result = edit_exact(&path, text("old_string"), text("new_string"))?; remember(path, observed); Ok(result) } /// Record the current version of `path` as the one the caller has seen. fn remember(path: PathBuf, observed: &mut HashMap) { if let Some(version) = FileVersion::of(&path) { observed.insert(path, version); } } /// The requested path, or the invocation directory when none was given. fn directory_or(path_str: &str, working_dir: &Path) -> PathBuf { if path_str.is_empty() { working_dir.to_path_buf() } else { resolve_path(path_str, working_dir) } } /// Ignore-aware, deterministic walk of `root`. /// /// Traversal order is sorted by path, and configured ignore files and hidden /// entries are honored, so identical trees always produce identical results. fn walker(root: &Path, max_depth: Option, config: &Config) -> ignore::Walk { let respect = config.respect_ignore_files; let mut builder = ignore::WalkBuilder::new(root); builder .max_depth(max_depth) .hidden(!config.include_hidden) .ignore(respect) .git_ignore(respect) .git_global(respect) .git_exclude(respect) .parents(respect) // Honor ignore files even outside a checkout, so listing behavior // does not depend on whether a VCS happens to be present. .require_git(false) .sort_by_file_path(std::cmp::Ord::cmp); builder.build() } /// Paths under `root`, relative to it, bounded and cancellable. fn walk_relative( root: &Path, max_depth: Option, config: &Config, cancel: &CancelHandle, code: &'static str, ) -> std::result::Result, SmithError> { let mut names = Vec::new(); for found in walker(root, max_depth, config) { if cancel.is_cancelled() { return Err(SmithError::Cancelled); } let found = found.map_err(|e| tool_err(code, e.to_string()))?; if found.depth() == 0 { continue; } names.push( found .path() .strip_prefix(root) .unwrap_or_else(|_| found.path()) .to_string_lossy() .into_owned(), ); if names.len() >= MAX_LIST_ENTRIES { break; } } Ok(names) } /// Search every non-ignored file under `root` for `query`. /// /// Matches are reported as `relative/path:line:text`, bounded by match count /// and total output, and unreadable or binary files are skipped rather than /// failing the whole search. fn grep_tree( root: &Path, query: &str, config: &Config, cancel: &CancelHandle, ) -> std::result::Result { let mut matches: Vec = Vec::new(); let mut total_bytes = 0usize; for found in walker(root, None, config) { if cancel.is_cancelled() { return Err(SmithError::Cancelled); } let found = found.map_err(|e| tool_err("GREP_ERROR", e.to_string()))?; if !found.file_type().is_some_and(|kind| kind.is_file()) { continue; } let relative = found .path() .strip_prefix(root) .unwrap_or_else(|_| found.path()) .to_string_lossy() .into_owned(); let Ok(file) = fs::File::open(found.path()) else { continue; }; let mut reader = BufReader::with_capacity(64 * 1024, file); let mut line = Vec::new(); let mut line_no = 0usize; loop { line.clear(); match reader.read_until(b'\n', &mut line) { Ok(0) | Err(_) => break, Ok(_) => {} } if line.contains(&0) { break; // binary content } line_no += 1; let text = String::from_utf8_lossy(&line).trim_end().to_string(); if !text.contains(query) { continue; } let rendered = format!("{relative}:{line_no}:{text}"); if matches.len() >= MAX_GREP_MATCHES || total_bytes + rendered.len() > DEFAULT_OUTPUT_CAP { matches.push("".to_string()); return Ok(matches.join("\n")); } total_bytes += rendered.len(); matches.push(rendered); } } Ok(matches.join("\n")) } /// Run one bounded, cancellable shell command in the invocation directory. fn run_bash( input: &serde_json::Value, working_dir: &Path, timeout_ms: u64, cancel: &CancelHandle, ) -> std::result::Result { let command = input .get("command") .and_then(|v| v.as_str()) .ok_or_else(|| tool_err("VALIDATION", "bash requires string 'command'"))?; let requested = input .get("timeout_ms") .and_then(serde_json::Value::as_u64) .unwrap_or(timeout_ms); let result = crate::bash::execute( command, working_dir.to_str(), requested.min(timeout_ms), DEFAULT_OUTPUT_CAP, cancel, )?; if result.cancelled { return Err(SmithError::Cancelled); } let mut output = result.stdout; if !result.stderr.is_empty() { if !output.is_empty() { output.push('\n'); } output.push_str("[stderr] "); output.push_str(&result.stderr); } if result.timed_out { return Err(tool_err( "BASH_TIMEOUT", format!("timed out after {requested} ms: {output}"), )); } match result.exit_code { Some(0) => Ok(output), Some(code) => Err(tool_err("BASH_EXIT", format!("exit {code}: {output}"))), None => Err(tool_err( "BASH_KILLED", format!("terminated without exit status: {output}"), )), } } /// Read a bounded line window without materializing the whole file. fn read_bounded( path: &Path, offset: usize, limit: usize, max_bytes: usize, ) -> std::result::Result { let file = fs::File::open(path).map_err(io_tool_err("READ_ERROR"))?; let mut reader = BufReader::with_capacity(64 * 1024, file); // Binary sniff on the first bytes. let sniff = reader.fill_buf().map_err(io_tool_err("READ_ERROR"))?; if sniff[..sniff.len().min(8192)].contains(&0) { return Err(tool_err( "BINARY_CONTENT", "file contains NUL bytes; binary content is unsupported", )); } let mut out = String::new(); let mut buf: Vec = Vec::with_capacity(8 * 1024); let mut line_no = 0usize; let mut bytes_out = 0usize; loop { buf.clear(); match reader.read_until(b'\n', &mut buf) { Ok(0) => break, Ok(_) => {} Err(e) => return Err(io_tool_err("READ_ERROR")(e)), } line_no += 1; if line_no <= offset { continue; } let emit = if limit == usize::MAX { true } else { (line_no - offset) <= limit }; if !emit { break; } if bytes_out + buf.len() > max_bytes { break; // keep fitting prefix only } bytes_out += buf.len(); out.push_str(&String::from_utf8_lossy(&buf)); if bytes_out >= max_bytes { break; } } Ok(out) } /// Atomically replace `path` with `content`: temp file in the same /// directory, synced, then renamed over the destination. fn write_atomic(path: &Path, content: &[u8]) -> std::result::Result<(), SmithError> { let parent = path.parent().filter(|p| !p.as_os_str().is_empty()); if let Some(parent) = parent { fs::create_dir_all(parent).map_err(io_tool_err("WRITE_MKDIR"))?; } let tmp = temp_sibling(path); let write = |tmp: &Path| -> std::io::Result<()> { let mut f = fs::File::create(tmp)?; f.write_all(content)?; f.sync_all()?; Ok(()) }; write(&tmp).map_err(io_tool_err("WRITE_ERROR"))?; fs::rename(&tmp, path).map_err(|e| { let _ = fs::remove_file(&tmp); io_tool_err("WRITE_PUBLISH")(e) })?; Ok(()) } /// A hidden sibling path used for atomic publication. fn temp_sibling(path: &Path) -> PathBuf { static COUNTER: AtomicUsize = AtomicUsize::new(0); let n = COUNTER.fetch_add(1, Ordering::Relaxed); let name = path.file_name().map_or_else( || "file".to_string(), |name| name.to_string_lossy().into_owned(), ); path.with_file_name(format!(".{name}.tmp-{}-{n}", std::process::id())) } /// Apply exactly one unambiguous replacement, publishing atomically. fn edit_exact(path: &Path, old: &str, new: &str) -> std::result::Result { let meta = fs::metadata(path).map_err(io_tool_err("EDIT_READ_ERROR"))?; if meta.len() > MAX_EDIT_FILE_BYTES { return Err(tool_err( "FILE_TOO_LARGE", "edit input exceeds the file size limit", )); } let bytes = fs::read(path).map_err(io_tool_err("EDIT_READ_ERROR"))?; let content = String::from_utf8(bytes) .map_err(|_| tool_err("EDIT_NOT_UTF8", "edit supports UTF-8 files only"))?; let count = content.matches(old).count(); if count == 0 { return Err(tool_err( "EDIT_NOT_FOUND", "old_string not found; file unchanged", )); } if count > 1 { return Err(tool_err( "EDIT_AMBIGUOUS", format!("old_string matches {count} locations; file unchanged"), )); } let updated = content.replacen(old, new, 1); write_atomic(path, updated.as_bytes())?; Ok("edited".to_string()) } /// Deterministic, bounded listing output. fn bounded_join(mut names: Vec, cap: usize) -> String { names.truncate(cap); let mut out = names.join("\n"); let max = DEFAULT_OUTPUT_CAP; if out.len() > max { out.truncate(max); out.push_str("\n"); } out } /// Stream matching lines with bounded memory and result count. fn grep_bounded(path: &Path, query: &str) -> std::result::Result { let file = fs::File::open(path).map_err(io_tool_err("GREP_READ_ERROR"))?; let reader = BufReader::with_capacity(64 * 1024, file); let mut matches: Vec = Vec::new(); let mut total_bytes = 0usize; for line in reader.lines() { let line = line.map_err(io_tool_err("GREP_READ_ERROR"))?; if line.contains(query) { if matches.len() >= MAX_GREP_MATCHES || total_bytes + line.len() > DEFAULT_OUTPUT_CAP { matches.push("".to_string()); break; } total_bytes += line.len(); matches.push(line); } } Ok(matches.join("\n")) } /// Wall-clock milliseconds since the Unix epoch, saturating on overflow. fn now_ms() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |elapsed| { i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX) }) } #[cfg(test)] #[expect( clippy::unwrap_used, clippy::panic, reason = "tests may panic on invariant violations" )] mod tests { use super::*; use crate::session::EntryContent; use crate::session::EntryKind; use std::path::PathBuf; fn workdir(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("smith_tools_{}_{}", tag, std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } #[test] fn listing_and_searching_respect_ignore_rules_and_hidden_entries() { let dir = workdir("ignore_rules"); std::fs::write(dir.join(".gitignore"), "ignored.txt\n").unwrap(); std::fs::write(dir.join("keep.txt"), "needle here\n").unwrap(); std::fs::write(dir.join("ignored.txt"), "needle here\n").unwrap(); std::fs::write(dir.join(".hidden.txt"), "needle here\n").unwrap(); std::fs::create_dir_all(dir.join("nested")).unwrap(); std::fs::write(dir.join("nested/deep.txt"), "needle deep\n").unwrap(); let mut ts = ToolSession::new(&dir); // find walks recursively and drops ignored and hidden paths. let found = ts .invoke("find", &serde_json::json!({"pattern": ""})) .unwrap() .output; assert_eq!(found, "keep.txt\nnested\nnested/deep.txt"); // ls stays one level deep, with the same filtering. let listed = ts.invoke("ls", &serde_json::json!({})).unwrap().output; assert_eq!(listed, "keep.txt\nnested"); // grep searches the tree and locates each match. let hits = ts .invoke("grep", &serde_json::json!({"path": "", "query": "needle"})) .unwrap() .output; assert_eq!( hits, "keep.txt:1:needle here\nnested/deep.txt:1:needle deep" ); // Configuration decides: without ignore rules, everything shows up. let mut config = Config::default_valid(); config.respect_ignore_files = false; config.include_hidden = true; let mut permissive = ToolSession::new(&dir).with_config(config); let found = permissive .invoke("find", &serde_json::json!({"pattern": ".txt"})) .unwrap() .output; assert_eq!(found, ".hidden.txt\nignored.txt\nkeep.txt\nnested/deep.txt"); let hits = permissive .invoke( "grep", &serde_json::json!({"path": "", "query": "needle here"}), ) .unwrap() .output; assert_eq!( hits, ".hidden.txt:1:needle here\nignored.txt:1:needle here\nkeep.txt:1:needle here" ); } #[test] fn edit_rejects_input_that_describes_an_older_version_of_the_file() { let dir = workdir("stale_edit"); let target = dir.join("doc.txt"); std::fs::write(&target, "alpha one\n").unwrap(); let mut ts = ToolSession::new(&dir); ts.invoke("read", &serde_json::json!({"path": "doc.txt"})) .unwrap(); // Somebody else rewrites the file after the read; same length, so only the mtime tells. std::fs::write(&target, "alpha two\n").unwrap(); // inject: pin the rewrite's mtime past the read instead of sleeping for a clock tick. let later = std::fs::metadata(&target).unwrap().modified().unwrap() + std::time::Duration::from_secs(60); std::fs::File::options() .write(true) .open(&target) .unwrap() .set_modified(later) .unwrap(); let err = ts .invoke( "edit", &serde_json::json!({"path": "doc.txt", "old_string": "alpha", "new_string": "beta"}), ) .unwrap_err(); assert_eq!(err.code(), "STALE_INPUT"); assert_eq!(std::fs::read_to_string(&target).unwrap(), "alpha two\n"); // Re-reading refreshes the observed version and unblocks the edit. ts.invoke("read", &serde_json::json!({"path": "doc.txt"})) .unwrap(); ts.invoke( "edit", &serde_json::json!({"path": "doc.txt", "old_string": "alpha", "new_string": "beta"}), ) .unwrap(); assert_eq!(std::fs::read_to_string(&target).unwrap(), "beta two\n"); } #[test] fn bash_effect_runs_in_the_workdir_and_is_recorded() { let dir = workdir("bash_effect"); let mut ts = ToolSession::new(&dir); let outcome = ts .invoke( "bash", &serde_json::json!({"command": "printf hi > made.txt; printf done"}), ) .unwrap(); assert!(outcome.ok); assert_eq!(outcome.output, "done"); assert_eq!(std::fs::read_to_string(dir.join("made.txt")).unwrap(), "hi"); // A recorded call always precedes its result. let kinds: Vec<&str> = ts .session .active_branch() .entries() .iter() .map(|e| match e.kind { EntryKind::ToolCall { .. } => "call", EntryKind::ToolResult { .. } => "result", _ => "other", }) .collect(); assert_eq!(kinds, vec!["call", "result"]); } #[test] fn failing_and_timing_out_commands_report_explicitly() { let dir = workdir("bash_failures"); let mut ts = ToolSession::new(&dir); let err = ts .invoke("bash", &serde_json::json!({"command": "exit 3"})) .unwrap_err(); assert_eq!(err.code(), "BASH_EXIT"); let err = ts .invoke( "bash", &serde_json::json!({"command": "sleep 5", "timeout_ms": 150}), ) .unwrap_err(); assert_eq!(err.code(), "BASH_TIMEOUT"); // Both failures stay durable evidence, recorded as failed results. let failed = ts .session .active_branch() .entries() .iter() .filter(|e| matches!(e.kind, EntryKind::ToolResult { ok: false, .. })) .count(); assert_eq!(failed, 2); } #[test] fn cancellation_stops_effects_before_they_start() { let dir = workdir("cancel_effects"); let mut ts = ToolSession::new(&dir); let cancel = ts.cancel_handle(); cancel.cancel(); let err = ts .invoke( "write", &serde_json::json!({"path": "never.txt", "content": "x"}), ) .unwrap_err(); assert_eq!(err.code(), "CANCELLED"); assert!(!dir.join("never.txt").exists()); // A refused call records nothing. assert!(ts.session.active_branch().entries().is_empty()); } #[test] fn cancelling_a_running_command_aborts_it() { let dir = workdir("cancel_running"); let mut ts = ToolSession::new(&dir); let cancel = ts.cancel_handle(); let signal = crate::process_signal::ProcessSignal::new(); let command = format!("{}; sleep 30", signal.raise()); let running = std::thread::spawn(move || { ts.invoke( "bash", &serde_json::json!({"command": command, "timeout_ms": 30_000}), ) }); // synchronize: cancel once the command reports it runs, never after a guessed delay. let mut socket = signal.wait(); cancel.cancel(); // Before the join: a survivor would hold the pipes and stall it. assert!( crate::process_signal::closed(&mut socket), "command kept running" ); let err = running.join().unwrap().unwrap_err(); assert_eq!(err.code(), "CANCELLED"); } #[test] fn unrecordable_result_after_a_mutation_reports_the_mutation() { let dir = workdir("record_failed"); let session_path = dir.join("session.smh"); let mut cfg = Config::default_valid(); cfg.max_frame_bytes = 1024; let opened = crate::store::open_session(&session_path, cfg.clone()).unwrap(); let mut ts = ToolSession::with_session(&dir, opened.session, opened.writer).with_config(cfg); // The call entry fits, the 5 KiB result does not: the effect already // ran, so the error has to admit the mutation. let err = ts .invoke( "bash", &serde_json::json!({"command": "printf 'x%.0s' $(seq 5000)"}), ) .unwrap_err(); assert_eq!(err.code(), "RECORD_FAILED"); assert!(matches!( err, SmithError::RecordFailed { mutation_applied: true, .. } )); // Only the durably recorded call is part of the live session. let kinds: Vec = ts .session .active_branch() .entries() .iter() .map(|e| e.kind) .collect(); assert!(matches!(kinds.as_slice(), [EntryKind::ToolCall { .. }])); } #[test] fn write_requires_string_content_and_leaves_existing_file_untouched() { let dir = workdir("write_schema"); let target = dir.join("data.txt"); std::fs::write(&target, "original").unwrap(); let mut ts = ToolSession::new(&dir); let err = ts .invoke("write", &serde_json::json!({"path": "data.txt"})) .unwrap_err(); assert_eq!(err.code(), "VALIDATION"); assert_eq!(std::fs::read_to_string(&target).unwrap(), "original"); let err = ts .invoke( "write", &serde_json::json!({"path": "data.txt", "content": 42}), ) .unwrap_err(); assert_eq!(err.code(), "VALIDATION"); assert_eq!(std::fs::read_to_string(&target).unwrap(), "original"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn write_creates_missing_parents_and_leaves_no_temp_files() { let dir = workdir("write_parents"); let mut ts = ToolSession::new(&dir); ts.invoke( "write", &serde_json::json!({"path": "a/b/c.txt", "content": "deep"}), ) .unwrap(); assert_eq!( std::fs::read_to_string(dir.join("a/b/c.txt")).unwrap(), "deep" ); let leftovers: Vec<_> = std::fs::read_dir(&dir) .unwrap() .filter_map(std::result::Result::ok) .flat_map(|e| { std::fs::read_dir(e.path()) .unwrap() .filter_map(std::result::Result::ok) .map(|x| x.file_name().to_string_lossy().into_owned()) .collect::>() }) .filter(|n| n.contains(".tmp-")) .collect(); assert!( leftovers.is_empty(), "temp files left behind: {leftovers:?}" ); let _ = std::fs::remove_dir_all(&dir); } #[test] fn edit_rejects_ambiguous_and_missing_matches_without_changes() { let dir = workdir("edit_amb"); let target = dir.join("f.txt"); std::fs::write(&target, "a a").unwrap(); let mut ts = ToolSession::new(&dir); let err = ts .invoke( "edit", &serde_json::json!({"path": "f.txt", "old_string": "a", "new_string": "b"}), ) .unwrap_err(); assert_eq!(err.code(), "EDIT_AMBIGUOUS"); assert_eq!(std::fs::read_to_string(&target).unwrap(), "a a"); let err = ts .invoke( "edit", &serde_json::json!({"path": "f.txt", "old_string": "NOT_PRESENT", "new_string": "b"}), ) .unwrap_err(); assert_eq!(err.code(), "EDIT_NOT_FOUND"); assert_eq!(std::fs::read_to_string(&target).unwrap(), "a a"); // Unambiguous edit succeeds. ts.invoke( "edit", &serde_json::json!({"path": "f.txt", "old_string": "a a", "new_string": "b"}), ) .unwrap(); assert_eq!(std::fs::read_to_string(&target).unwrap(), "b"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn read_supports_line_windows_byte_caps_and_binary_detection() { let dir = workdir("read_caps"); let mut body = String::new(); for i in 0..100 { use std::fmt::Write as _; let _ = writeln!(body, "line-{i}"); } std::fs::write(dir.join("lines.txt"), &body).unwrap(); let mut ts = ToolSession::new(&dir); let out = ts .invoke( "read", &serde_json::json!({"path": "lines.txt", "offset": 2, "limit": 3}), ) .unwrap(); assert_eq!(out.output, "line-2\nline-3\nline-4\n"); let capped = ts .invoke( "read", &serde_json::json!({"path": "lines.txt", "max_bytes": 20}), ) .unwrap(); assert!(capped.output.len() <= 20); std::fs::write(dir.join("bin.dat"), [0x62, 0x00, 0x62]).unwrap(); let err = ts .invoke("read", &serde_json::json!({"path": "bin.dat"})) .unwrap_err(); assert_eq!(err.code(), "BINARY_CONTENT"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn ls_and_find_are_sorted_and_surface_errors() { let dir = workdir("ls_find"); for name in ["zeta", "alpha", "mid"] { std::fs::write(dir.join(name), "").unwrap(); } let mut ts = ToolSession::new(&dir); let out = ts.invoke("ls", &serde_json::json!({})).unwrap(); assert_eq!(out.output, "alpha\nmid\nzeta"); let found = ts .invoke("find", &serde_json::json!({"pattern": "a"})) .unwrap(); assert_eq!(found.output, "alpha\nzeta"); let err = ts .invoke( "find", &serde_json::json!({"path": "missing-dir", "pattern": "x"}), ) .unwrap_err(); assert_eq!(err.code(), "FIND_ERROR"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn grep_returns_bounded_matches() { let dir = workdir("grep"); std::fs::write( dir.join("log.txt"), "hit one\nmiss\nhit two\nmiss\nhit three\n", ) .unwrap(); let mut ts = ToolSession::new(&dir); let out = ts .invoke( "grep", &serde_json::json!({"path": "log.txt", "query": "hit"}), ) .unwrap(); assert_eq!(out.output, "hit one\nhit two\nhit three"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn unregistered_tools_and_non_object_input_are_rejected() { let dir = workdir("registry"); let mut ts = ToolSession::new(&dir); let err = ts.invoke("rm", &serde_json::json!({})).unwrap_err(); assert_eq!(err.code(), "TOOL_NOT_REGISTERED"); let err = ts .invoke("read", &serde_json::json!("not-an-object")) .unwrap_err(); assert_eq!(err.code(), "INVALID_INPUT"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn failed_effects_are_recorded_as_failures() { let dir = workdir("record_fail"); let mut ts = ToolSession::new(&dir); let res = ts.invoke("read", &serde_json::json!({"path": "nope.txt"})); assert!(res.is_err()); // Two entries: call + failed result, both recorded. assert_eq!(ts.session.active_branch().entries().len(), 2); let last = &ts.session.active_branch().entries()[1]; match ts.session.content(last).unwrap() { EntryContent::ToolResult { ok, output, .. } => { assert!(!ok); assert!(output.contains("READ_ERROR")); } other => panic!("unexpected {other:?}"), } let _ = std::fs::remove_dir_all(&dir); } }