Luigit
repositories / smith

smith

There are many coding harnesses - but this one is fast

owned by admin

smith-core/src/tools.rs

Raw
//! 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::metadata::{ToolMutationMetadata, encode as encode_metadata};
use crate::session::{Entry, EntryContentRef, Session, TOOL_MUTATION_KIND};
use crate::store::SessionWriter;
use smith::config::Config;
use smith::error::{MutationState, Result, SmithError};
use smith::id::ToolCallId;
use smith::structured::{
    Kind, SchemaError, StructuredData, StructuredValue, ToolArguments, ToolSchema,
};
use smith::tool::{CancelHandle, ToolMetadata};
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};

const HARD_OUTPUT_CAP: usize = 256 * 1024;
const DEFAULT_OUTPUT_CAP: usize = 64 * 1024;
const LIST_ENTRIES_MAX: usize = 1000;
const GREP_MATCHES_MAX: usize = 200;
const GREP_LINE_BYTES_MAX: usize = DEFAULT_OUTPUT_CAP;
const EDIT_FILE_BYTES_MAX: usize = 8 * 1024 * 1024;

/// Read one LF-delimited line without materializing more than `bytes_max` bytes.
fn read_line_bounded(
    reader: &mut impl BufRead,
    line: &mut Vec<u8>,
    bytes_max: usize,
) -> std::io::Result<bool> {
    #[expect(
        clippy::disallowed_methods,
        reason = "Take caps one line before overflow is reported"
    )]
    let bytes_read = reader
        .take((bytes_max + 1) as u64)
        .read_until(b'\n', line)?;
    if bytes_read == 0 {
        return Ok(false);
    }
    let line_ended = line.ends_with(b"\n") || reader.fill_buf()?.is_empty();
    if line.len() > bytes_max || !line_ended {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            format!("line exceeds {bytes_max} bytes"),
        ));
    }
    Ok(true)
}

/// Registry of available tools.
#[derive(Clone, Debug, Default)]
pub struct ToolRegistry {
    /// Tools by name.
    tools: HashMap<String, ToolMetadata>,
    /// 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<ToolMetadata> = 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<ToolMetadata> {
    vec![
        ToolMetadata::new("read", "Read a bounded line window from a file.").with_schema(
            ToolSchema::object(
                &[
                    ("path", "string"),
                    ("offset", "integer"),
                    ("limit", "integer"),
                    ("max_bytes", "integer"),
                ],
                &["path"],
            ),
        ),
        ToolMetadata::new("write", "Atomically replace a file, creating parents.").with_schema(
            ToolSchema::object(
                &[("path", "string"), ("content", "string")],
                &["path", "content"],
            ),
        ),
        ToolMetadata::new("edit", "Apply one exact, unambiguous replacement.").with_schema(
            ToolSchema::object(
                &[
                    ("path", "string"),
                    ("old_string", "string"),
                    ("new_string", "string"),
                ],
                &["path", "old_string", "new_string"],
            ),
        ),
        ToolMetadata::new("ls", "List a directory deterministically.")
            .with_schema(ToolSchema::object(&[("path", "string")], &[])),
        ToolMetadata::new("find", "Find directory entries matching a substring.").with_schema(
            ToolSchema::object(&[("path", "string"), ("pattern", "string")], &["pattern"]),
        ),
        ToolMetadata::new("grep", "Return bounded matching lines from a file.").with_schema(
            ToolSchema::object(
                &[("path", "string"), ("query", "string")],
                &["path", "query"],
            ),
        ),
        ToolMetadata::new("bash", "Run a bounded cancellable shell command.").with_schema(
            ToolSchema::object(
                &[("command", "string"), ("timeout_ms", "integer")],
                &["command"],
            ),
        ),
    ]
}

type PersistEntry = dyn FnMut(&Entry, &[u8]) -> Result<()> + Send;

enum Persistence {
    None,
    Direct(SessionWriter),
    Worker(Box<PersistEntry>),
}

/// Bundles the registry, the live session, and durable persistence so a
/// successful mutation cannot be reported while recording failed.
pub struct ToolSession {
    registry: ToolRegistry,
    /// Live session state; entries are appended as they are recorded.
    pub session: Session,
    persistence: Persistence,
    /// 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<PathBuf, FileVersion>,
    /// Completion persistence failed; no further host effects may begin.
    recording_failed: bool,
    #[cfg(test)]
    test_effect_control: TestEffectControl,
}

/// 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<Self> {
        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<PathBuf>) -> Self {
        Self {
            registry: ToolRegistry::with_builtins(),
            session: Session::new(),
            persistence: Persistence::None,
            working_dir: working_dir.into(),
            config: Config::default_valid(),
            cancel: CancelHandle::new(),
            observed: HashMap::new(),
            recording_failed: false,
            #[cfg(test)]
            test_effect_control: TestEffectControl::default(),
        }
    }

    /// 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;
    }

    #[cfg(test)]
    const fn cancel_at_checkpoint(&mut self, checkpoint: EffectCheckpoint) {
        self.test_effect_control.cancel_at = Some(checkpoint);
    }

    #[cfg(test)]
    const fn timeout_at_checkpoint(&mut self, checkpoint: EffectCheckpoint) {
        self.test_effect_control.timeout_at = Some(checkpoint);
    }

    /// Continue an existing session, appending every recorded entry to its
    /// durable writer.
    pub fn with_session(
        working_dir: impl Into<PathBuf>,
        session: Session,
        writer: SessionWriter,
    ) -> Self {
        Self {
            session,
            persistence: Persistence::Direct(writer),
            ..Self::new(working_dir)
        }
    }

    /// Keep persistence on an external storage worker.
    #[must_use]
    pub fn with_persist(
        working_dir: impl Into<PathBuf>,
        session: Session,
        persist: impl FnMut(&Entry, &[u8]) -> Result<()> + Send + 'static,
    ) -> Self {
        Self {
            session,
            persistence: Persistence::Worker(Box::new(persist)),
            ..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<'a, Input: StructuredValue<'a>>(&self, name: &str, input: Input) -> Result<()> {
        let meta = self.registry.get(name).ok_or_else(|| {
            tool_error(
                smith::error::ToolCode::ToolNotRegistered,
                format!("tool '{name}' not registered"),
            )
        })?;
        meta.input_schema.validate(input).map_err(|error| {
            let (code, message) = match error {
                SchemaError::InputNotObject => (
                    smith::error::ToolCode::InvalidInput,
                    "input must be a JSON object".to_string(),
                ),
                SchemaError::MissingField(field) => (
                    smith::error::ToolCode::Validation,
                    format!("missing required field '{field}' for tool '{name}'"),
                ),
                SchemaError::FieldType { field, expected } => (
                    smith::error::ToolCode::Validation,
                    format!("field '{field}' for tool '{name}' must be of type {expected}"),
                ),
                SchemaError::Access(error) => {
                    (smith::error::ToolCode::Validation, error.to_string())
                }
            };
            tool_error(code, message)
        })
    }

    /// 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: &ToolArguments) -> Result<ToolOutcome> {
        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: &ToolArguments,
        effect_input: &ToolArguments,
        redact_output: impl Fn(&str) -> String,
    ) -> Result<ToolOutcome> {
        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: &ToolArguments,
        effect_input: &ToolArguments,
        redact_output: impl Fn(&str) -> String,
        failure: EffectFailure,
    ) -> Result<ToolOutcome> {
        self.validate(name, recorded_input.view())?;
        if self.recording_failed {
            return Err(SmithError::RecordFailed {
                tool: name.to_string(),
                mutation_state: MutationState::Unknown,
                message: "a prior completion recording failure stopped further effects".to_string(),
            });
        }
        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,
                #[cfg(test)]
                self.test_effect_control,
            )
        };
        let ok = effect.is_ok();
        let mutation_state = mutation_state(name, &effect);
        let output = match &effect {
            Ok(text) => redact_output(text),
            Err(error) => redact_output(&error.to_string()),
        };
        let detail = mutation_detail(call_id, mutation_state)?;
        let mut completion = || -> Result<()> {
            self.record(EntryContentRef::ToolResult {
                ok,
                output: &output,
                call_id,
            })?;
            self.record(EntryContentRef::Meta {
                kind: TOOL_MUTATION_KIND,
                detail: &detail,
            })?;
            Ok(())
        };
        if let Err(error) = completion() {
            self.recording_failed = true;
            self.cancel.cancel();
            return Err(SmithError::RecordFailed {
                tool: name.to_string(),
                mutation_state,
                message: error.to_string(),
            });
        }
        let outcome = ToolOutcome {
            call_id,
            name: name.to_string(),
            ok,
            output,
            timestamp_ms: now_ms(),
            mutation_state,
        };
        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<EntryContentRef<'a>>) -> Result<Entry> {
        let persistence = &mut self.persistence;
        let entry = self
            .session
            .append_with(content, |entry, bytes| match persistence {
                Persistence::None => Ok(()),
                Persistence::Direct(writer) => writer.append_entry(entry, bytes),
                Persistence::Worker(persist) => persist(entry, bytes),
            })
            .copied()?;
        if entry.is_compaction() {
            self.session.retain_compacted_context();
        }
        Ok(entry)
    }
}

#[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,
    /// Durable best-known host mutation outcome.
    pub mutation_state: MutationState,
}

fn mutation_detail(call_id: ToolCallId, mutation_state: MutationState) -> Result<StructuredData> {
    encode_metadata(&ToolMutationMetadata::new(call_id, mutation_state))
}

fn tool_error(code: smith::error::ToolCode, message: impl Into<String>) -> SmithError {
    SmithError::Tool {
        code,
        message: message.into(),
    }
}

fn io_tool_error(code: smith::error::ToolCode) -> impl Fn(std::io::Error) -> SmithError {
    move |e| tool_error(code, e.to_string())
}

fn input_string<'a>(input: &'a ToolArguments, key: &str) -> Result<Option<&'a str>> {
    let Some(value) = input.view().field(key).map_err(structured_tool_error)? else {
        return Ok(None);
    };
    match value.kind().map_err(structured_tool_error)? {
        Kind::String(value) => Ok(Some(value)),
        _ => Ok(None),
    }
}

fn input_unsigned(input: &ToolArguments, key: &str) -> Result<Option<u64>> {
    let Some(value) = input.view().field(key).map_err(structured_tool_error)? else {
        return Ok(None);
    };
    match value.kind().map_err(structured_tool_error)? {
        Kind::Unsigned(value) => Ok(Some(value)),
        _ => Ok(None),
    }
}

fn structured_tool_error(error: smith::structured::AccessError) -> SmithError {
    tool_error(smith::error::ToolCode::Validation, error.to_string())
}

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.
/// Infer only what effect-boundary evidence proves.
///
/// A shell's exit status says nothing about arbitrary commands it may have
/// executed, so every spawned shell is `Unknown`; only spawn failure proves
/// `NotApplied`. File publication and parent creation provide direct evidence.
fn mutation_state(name: &str, effect: &std::result::Result<String, SmithError>) -> MutationState {
    match name {
        "bash" => match effect {
            Err(
                SmithError::Cancelled
                | SmithError::Tool {
                    code: smith::error::ToolCode::BashSpawn,
                    ..
                },
            ) => MutationState::NotApplied,
            _ => MutationState::Unknown,
        },
        "write" | "edit" => match effect {
            Ok(_) => MutationState::Applied,
            Err(SmithError::Tool { code, .. })
                if matches!(code.as_str(), "CANCELLED_APPLIED" | "TOOL_TIMEOUT_APPLIED") =>
            {
                MutationState::Applied
            }
            Err(SmithError::Cancelled) => MutationState::NotApplied,
            Err(_) => MutationState::Unknown,
        },
        _ => MutationState::NotApplied,
    }
}

/// Cooperative checkpoints placed at bounded filesystem work boundaries.
#[derive(Clone, Copy, PartialEq, Eq)]
enum EffectCheckpoint {
    Start,
    TraversalEntry,
    ReadChunk,
    GrepLine,
    EditReadChunk,
    WriteChunk,
    ParentCreated,
    BeforePublish,
    AfterPublish,
}

#[cfg(test)]
#[derive(Clone, Copy, Default)]
struct TestEffectControl {
    cancel_at: Option<EffectCheckpoint>,
    timeout_at: Option<EffectCheckpoint>,
}

/// Cooperative cancellation and deadline checks around filesystem chunks.
///
/// Standard filesystem calls can block without a portable interruption API.
/// Smith therefore checks before and after bounded reads and writes, and
/// immediately before publication. A cancellation observed after `rename`
/// reports that the mutation was already applied; it never claims rollback.
struct EffectControl<'a> {
    cancel: &'a CancelHandle,
    deadline: Instant,
    #[cfg(test)]
    test_control: TestEffectControl,
}

impl<'a> EffectControl<'a> {
    fn new(
        cancel: &'a CancelHandle,
        timeout_ms: u64,
        #[cfg(test)] test_control: TestEffectControl,
    ) -> Self {
        Self {
            cancel,
            deadline: Instant::now() + Duration::from_millis(timeout_ms),
            #[cfg(test)]
            test_control,
        }
    }

    fn checkpoint(&self, _checkpoint: EffectCheckpoint, mutation_applied: bool) -> Result<()> {
        #[cfg(test)]
        if self.test_control.cancel_at == Some(_checkpoint) {
            self.cancel.cancel();
        }
        #[cfg(test)]
        if self.test_control.timeout_at == Some(_checkpoint) {
            return Err(Self::timeout_error(mutation_applied));
        }
        if self.cancel.is_cancelled() {
            return Err(if mutation_applied {
                tool_error(
                    smith::error::ToolCode::CancelledApplied,
                    "cancellation arrived after the file mutation was applied",
                )
            } else {
                SmithError::Cancelled
            });
        }
        if Instant::now() >= self.deadline {
            return Err(Self::timeout_error(mutation_applied));
        }
        Ok(())
    }

    fn timeout_error(mutation_applied: bool) -> SmithError {
        tool_error(
            if mutation_applied {
                smith::error::ToolCode::BashTimeoutApplied
            } else {
                smith::error::ToolCode::ToolTimeout
            },
            if mutation_applied {
                "tool deadline elapsed after the file mutation was applied"
            } else {
                "tool deadline elapsed before the effect completed"
            },
        )
    }
}

fn execute_effect(
    name: &str,
    input: &ToolArguments,
    working_dir: &Path,
    config: &Config,
    cancel: &CancelHandle,
    observed: &mut HashMap<PathBuf, FileVersion>,
    #[cfg(test)] test_effect_control: TestEffectControl,
) -> std::result::Result<String, SmithError> {
    let control = EffectControl::new(
        cancel,
        config.tool_timeout_ms,
        #[cfg(test)]
        test_effect_control,
    );
    control.checkpoint(EffectCheckpoint::Start, false)?;
    let path_str = input_string(input, "path")?.unwrap_or_default();
    match name {
        "bash" => run_bash(input, working_dir, config.tool_timeout_ms, cancel),
        "read" => read_effect(
            input,
            resolve_path(path_str, working_dir),
            observed,
            &control,
        ),
        "write" => write_effect(
            input,
            resolve_path(path_str, working_dir),
            observed,
            &control,
        ),
        "edit" => edit_effect(
            input,
            resolve_path(path_str, working_dir),
            observed,
            &control,
        ),
        "ls" => {
            let dir = directory_or(path_str, working_dir);
            if !dir.is_dir() {
                return Err(tool_error(
                    smith::error::ToolCode::LsError,
                    format!("{} is not a directory", dir.display()),
                ));
            }
            let names = walk_relative(
                &dir,
                Some(1),
                config,
                &control,
                smith::error::ToolCode::LsError,
            )?;
            Ok(bounded_join(names, LIST_ENTRIES_MAX))
        }
        "find" => {
            let pattern = input_string(input, "pattern")?.unwrap_or_default();
            let root = directory_or(path_str, working_dir);
            if !root.is_dir() {
                return Err(tool_error(
                    smith::error::ToolCode::FindError,
                    format!("{} is not a directory", root.display()),
                ));
            }
            let names = walk_relative(
                &root,
                None,
                config,
                &control,
                smith::error::ToolCode::FindError,
            )?
            .into_iter()
            .filter(|name| pattern.is_empty() || name.contains(pattern))
            .collect();
            Ok(bounded_join(names, LIST_ENTRIES_MAX))
        }
        "grep" => {
            let query = input_string(input, "query")?.unwrap_or_default();
            let target = directory_or(path_str, working_dir);
            if target.is_dir() {
                grep_tree(&target, query, config, &control)
            } else {
                grep_bounded(&target, query, &control)
            }
        }
        _ => Err(tool_error(
            smith::error::ToolCode::ToolNotImplemented,
            format!("tool '{name}' has no built-in effect"),
        )),
    }
}

/// Read a bounded window and remember the version that was shown.
fn read_effect(
    input: &ToolArguments,
    path: PathBuf,
    observed: &mut HashMap<PathBuf, FileVersion>,
    control: &EffectControl<'_>,
) -> std::result::Result<String, SmithError> {
    let count = |key: &str| {
        input_unsigned(input, key)
            .map(|value| value.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)),
        control,
    )?;
    remember(path, observed);
    Ok(text)
}

/// Replace a file atomically and remember the version just written.
fn write_effect(
    input: &ToolArguments,
    path: PathBuf,
    observed: &mut HashMap<PathBuf, FileVersion>,
    control: &EffectControl<'_>,
) -> std::result::Result<String, SmithError> {
    let content = input_string(input, "content")?.ok_or_else(|| {
        tool_error(
            smith::error::ToolCode::Validation,
            "write requires string 'content'",
        )
    })?;
    write_atomic(&path, content.as_bytes(), control)?;
    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: &ToolArguments,
    path: PathBuf,
    observed: &mut HashMap<PathBuf, FileVersion>,
    control: &EffectControl<'_>,
) -> std::result::Result<String, SmithError> {
    let old = input_string(input, "old_string")?.unwrap_or_default();
    let new = input_string(input, "new_string")?.unwrap_or_default();
    if let Some(seen) = observed.get(&path)
        && FileVersion::of(&path) != Some(*seen)
    {
        return Err(tool_error(
            smith::error::ToolCode::StaleInput,
            format!(
                "{} changed since it was last read; read it again before editing",
                path.display()
            ),
        ));
    }
    let result = edit_exact(&path, old, new, control)?;
    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<PathBuf, FileVersion>) {
    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, depth_max: Option<usize>, config: &Config) -> ignore::Walk {
    let respect = config.respect_ignore_files;
    let mut builder = ignore::WalkBuilder::new(root);
    builder
        .max_depth(depth_max)
        .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,
    depth_max: Option<usize>,
    config: &Config,
    control: &EffectControl<'_>,
    code: smith::error::ToolCode,
) -> std::result::Result<Vec<String>, SmithError> {
    let mut names = Vec::new();
    for found in walker(root, depth_max, config) {
        control.checkpoint(EffectCheckpoint::TraversalEntry, false)?;
        let found = found.map_err(|e| tool_error(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() >= LIST_ENTRIES_MAX {
            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,
    control: &EffectControl<'_>,
) -> std::result::Result<String, SmithError> {
    let mut matches: Vec<String> = Vec::new();
    let mut total_bytes = 0usize;
    for found in walker(root, None, config) {
        control.checkpoint(EffectCheckpoint::TraversalEntry, false)?;
        let found =
            found.map_err(|e| tool_error(smith::error::ToolCode::GrepError, 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 {
            control.checkpoint(EffectCheckpoint::GrepLine, false)?;
            line.clear();
            if !matches!(
                read_line_bounded(&mut reader, &mut line, GREP_LINE_BYTES_MAX),
                Ok(true)
            ) {
                break;
            }
            control.checkpoint(EffectCheckpoint::GrepLine, false)?;
            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() >= GREP_MATCHES_MAX
                || total_bytes + rendered.len() > DEFAULT_OUTPUT_CAP
            {
                matches.push("<truncated>".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: &ToolArguments,
    working_dir: &Path,
    timeout_ms: u64,
    cancel: &CancelHandle,
) -> std::result::Result<String, SmithError> {
    let command = input_string(input, "command")?.ok_or_else(|| {
        tool_error(
            smith::error::ToolCode::Validation,
            "bash requires string 'command'",
        )
    })?;
    let requested = input_unsigned(input, "timeout_ms")?.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(tool_error(
            smith::error::ToolCode::CancelledUnknown,
            "shell was cancelled after spawning; mutation state is unknown",
        ));
    }
    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_error(
            smith::error::ToolCode::BashTimeout,
            format!("timed out after {requested} ms: {output}"),
        ));
    }
    match result.exit_code {
        Some(0) => Ok(output),
        Some(code) => Err(tool_error(
            smith::error::ToolCode::BashExit,
            format!("exit {code}: {output}"),
        )),
        None => Err(tool_error(
            smith::error::ToolCode::BashKilled,
            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,
    bytes_max: usize,
    control: &EffectControl<'_>,
) -> std::result::Result<String, SmithError> {
    control.checkpoint(EffectCheckpoint::ReadChunk, false)?;
    let file = fs::File::open(path).map_err(io_tool_error(smith::error::ToolCode::ReadError))?;
    control.checkpoint(EffectCheckpoint::ReadChunk, false)?;
    let mut reader = BufReader::with_capacity(64 * 1024, file);
    // Binary sniff on the first bytes.
    let sniff = reader
        .fill_buf()
        .map_err(io_tool_error(smith::error::ToolCode::ReadError))?;
    control.checkpoint(EffectCheckpoint::ReadChunk, false)?;
    if sniff[..sniff.len().min(8192)].contains(&0) {
        return Err(tool_error(
            smith::error::ToolCode::BinaryContent,
            "file contains NUL bytes; binary content is unsupported",
        ));
    }
    let mut out = String::new();
    let mut buffer: Vec<u8> = Vec::with_capacity(8 * 1024);
    let mut line_no = 0usize;
    let mut bytes_out = 0usize;
    loop {
        control.checkpoint(EffectCheckpoint::ReadChunk, false)?;
        buffer.clear();
        #[expect(
            clippy::disallowed_methods,
            reason = "Take caps one read-tool line before truncation is reported"
        )]
        let bytes_read = (&mut reader)
            .take((DEFAULT_OUTPUT_CAP + 1) as u64)
            .read_until(b'\n', &mut buffer)
            .map_err(io_tool_error(smith::error::ToolCode::ReadError))?;
        let line_ended = buffer.ends_with(b"\n")
            || reader
                .fill_buf()
                .map_err(io_tool_error(smith::error::ToolCode::ReadError))?
                .is_empty();
        if bytes_read == 0 || buffer.len() > DEFAULT_OUTPUT_CAP || !line_ended {
            break;
        }
        control.checkpoint(EffectCheckpoint::ReadChunk, false)?;
        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 + buffer.len() > bytes_max {
            break; // keep fitting prefix only
        }
        bytes_out += buffer.len();
        out.push_str(&String::from_utf8_lossy(&buffer));
        if bytes_out >= bytes_max {
            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],
    control: &EffectControl<'_>,
) -> std::result::Result<(), SmithError> {
    control.checkpoint(EffectCheckpoint::WriteChunk, false)?;
    let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
    if let Some(parent) = parent {
        let parent_existed = parent.is_dir();
        fs::create_dir_all(parent).map_err(io_tool_error(smith::error::ToolCode::WriteMkdir))?;
        control.checkpoint(EffectCheckpoint::ParentCreated, !parent_existed)?;
        control.checkpoint(EffectCheckpoint::WriteChunk, !parent_existed)?;
    }
    let temporary = temp_sibling(path);
    let write = || -> std::result::Result<(), SmithError> {
        let mut file = fs::File::create(&temporary)
            .map_err(io_tool_error(smith::error::ToolCode::WriteError))?;
        control.checkpoint(EffectCheckpoint::WriteChunk, false)?;
        for chunk in content.chunks(64 * 1024) {
            control.checkpoint(EffectCheckpoint::WriteChunk, false)?;
            file.write_all(chunk)
                .map_err(io_tool_error(smith::error::ToolCode::WriteError))?;
            control.checkpoint(EffectCheckpoint::WriteChunk, false)?;
        }
        control.checkpoint(EffectCheckpoint::WriteChunk, false)?;
        file.sync_all()
            .map_err(io_tool_error(smith::error::ToolCode::WriteError))?;
        control.checkpoint(EffectCheckpoint::BeforePublish, false)
    };
    if let Err(error) = write() {
        // Preserve the write failure; temporary-file removal is best-effort cleanup.
        let _cleanup_result: std::io::Result<()> = fs::remove_file(&temporary);
        return Err(error);
    }
    control.checkpoint(EffectCheckpoint::BeforePublish, false)?;
    fs::rename(&temporary, path).map_err(|error| {
        // Preserve the publish failure; temporary-file removal is best-effort cleanup.
        let _cleanup_result: std::io::Result<()> = fs::remove_file(&temporary);
        io_tool_error(smith::error::ToolCode::WritePublish)(error)
    })?;
    control.checkpoint(EffectCheckpoint::AfterPublish, true)
}

/// 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,
    control: &EffectControl<'_>,
) -> std::result::Result<String, SmithError> {
    let meta = fs::metadata(path).map_err(io_tool_error(smith::error::ToolCode::EditReadError))?;
    if meta.len() > u64::try_from(EDIT_FILE_BYTES_MAX).unwrap_or(u64::MAX) {
        return Err(tool_error(
            smith::error::ToolCode::FileTooLarge,
            "edit input exceeds the file size limit",
        ));
    }
    let bytes = read_edit_input(path, meta.len(), control)?;
    let content = String::from_utf8(bytes).map_err(|_| {
        tool_error(
            smith::error::ToolCode::EditNotUtf8,
            "edit supports UTF-8 files only",
        )
    })?;
    let count = content.matches(old).count();
    if count == 0 {
        return Err(tool_error(
            smith::error::ToolCode::EditNotFound,
            "old_string not found; file unchanged",
        ));
    }
    if count > 1 {
        return Err(tool_error(
            smith::error::ToolCode::EditAmbiguous,
            format!("old_string matches {count} locations; file unchanged"),
        ));
    }
    let updated = content.replacen(old, new, 1);
    write_atomic(path, updated.as_bytes(), control)?;
    Ok("edited".to_string())
}

/// Read edit input in bounded chunks so cancellation stays live between reads.
fn read_edit_input(
    path: &Path,
    expected_len: u64,
    control: &EffectControl<'_>,
) -> std::result::Result<Vec<u8>, SmithError> {
    control.checkpoint(EffectCheckpoint::EditReadChunk, false)?;
    let capacity = usize::try_from(expected_len).unwrap_or(EDIT_FILE_BYTES_MAX);
    let mut file =
        fs::File::open(path).map_err(io_tool_error(smith::error::ToolCode::EditReadError))?;
    let mut bytes = Vec::with_capacity(capacity);
    let mut chunk = [0_u8; 16 * 1024];
    loop {
        control.checkpoint(EffectCheckpoint::EditReadChunk, false)?;
        let read = file
            .read(&mut chunk)
            .map_err(io_tool_error(smith::error::ToolCode::EditReadError))?;
        control.checkpoint(EffectCheckpoint::EditReadChunk, false)?;
        if read == 0 {
            break;
        }
        if bytes.len() + read > EDIT_FILE_BYTES_MAX {
            return Err(tool_error(
                smith::error::ToolCode::FileTooLarge,
                "edit input exceeds the file size limit",
            ));
        }
        bytes.extend_from_slice(&chunk[..read]);
    }
    Ok(bytes)
}

/// Deterministic, bounded listing output.
fn bounded_join(mut names: Vec<String>, 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<truncated>");
    }
    out
}

/// Stream matching lines with bounded memory and result count.
fn grep_bounded(
    path: &Path,
    query: &str,
    control: &EffectControl<'_>,
) -> std::result::Result<String, SmithError> {
    control.checkpoint(EffectCheckpoint::GrepLine, false)?;
    let file =
        fs::File::open(path).map_err(io_tool_error(smith::error::ToolCode::GrepReadError))?;
    control.checkpoint(EffectCheckpoint::GrepLine, false)?;
    let mut reader = BufReader::with_capacity(64 * 1024, file);
    let mut matches: Vec<String> = Vec::new();
    let mut total_bytes = 0usize;
    loop {
        control.checkpoint(EffectCheckpoint::GrepLine, false)?;
        let mut line = Vec::new();
        #[expect(
            clippy::disallowed_methods,
            reason = "Take caps one grep line before overflow is reported"
        )]
        let bytes_read = (&mut reader)
            .take((GREP_LINE_BYTES_MAX + 1) as u64)
            .read_until(b'\n', &mut line)
            .map_err(io_tool_error(smith::error::ToolCode::GrepReadError))?;
        if bytes_read == 0 {
            break;
        }
        let line_ended = line.ends_with(b"\n")
            || reader
                .fill_buf()
                .map_err(io_tool_error(smith::error::ToolCode::GrepReadError))?
                .is_empty();
        if line.len() > GREP_LINE_BYTES_MAX || !line_ended {
            matches.push("<truncated>".to_string());
            break;
        }
        if line.ends_with(b"\n") {
            line.pop();
        }
        if line.last() == Some(&b'\r') {
            line.pop();
        }
        control.checkpoint(EffectCheckpoint::GrepLine, false)?;
        let line = String::from_utf8(line).map_err(|error| {
            io_tool_error(smith::error::ToolCode::GrepReadError)(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                error,
            ))
        })?;
        if line.contains(query) {
            if matches.len() >= GREP_MATCHES_MAX || total_bytes + line.len() > DEFAULT_OUTPUT_CAP {
                matches.push("<truncated>".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::*;

    macro_rules! test_args {
        ($($value:tt)*) => {
            ToolArguments::from_serializable(&serde_json::json!($($value)*)).unwrap()
        };
    }
    use crate::process_signal::{ProcessSignal, closed};
    use crate::session::EntryContent;
    use crate::session::EntryKind;
    use std::path::PathBuf;
    use std::sync::mpsc;
    use std::thread;

    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", &test_args!({"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", &test_args!({})).unwrap().output;
        assert_eq!(listed, "keep.txt\nnested");
        // grep searches the tree and locates each match.
        let hits = ts
            .invoke("grep", &test_args!({"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", &test_args!({"pattern": ".txt"}))
            .unwrap()
            .output;
        assert_eq!(found, ".hidden.txt\nignored.txt\nkeep.txt\nnested/deep.txt");
        let hits = permissive
            .invoke("grep", &test_args!({"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", &test_args!({"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 error = ts
            .invoke(
                "edit",
                &test_args!({"path": "doc.txt", "old_string": "alpha", "new_string": "beta"}),
            )
            .unwrap_err();
        assert_eq!(error.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", &test_args!({"path": "doc.txt"})).unwrap();
        ts.invoke(
            "edit",
            &test_args!({"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",
                &test_args!({"command": "printf hi > made.txt; printf done"}),
            )
            .unwrap();
        assert!(outcome.ok);
        assert_eq!(outcome.mutation_state, MutationState::Unknown);
        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", "other"]);
        assert_eq!(
            ts.session.mutation_state(outcome.call_id).unwrap(),
            MutationState::Unknown
        );
    }

    #[test]
    fn cancelled_shell_after_write_records_unknown_mutation_state() {
        let dir = workdir("bash_cancelled_mutation");
        let signal = ProcessSignal::new();
        let command = format!("printf made > made.txt; {}; sleep 30", signal.raise());
        let mut ts = ToolSession::new(&dir);
        let cancel = ts.cancel_handle();
        let (done, finished) = mpsc::channel();
        thread::spawn(move || {
            let error = ts
                .invoke(
                    "bash",
                    &test_args!({"command": command, "timeout_ms": 30_000}),
                )
                .unwrap_err();
            done.send((error, ts)).unwrap();
        });
        let mut socket = signal.wait();
        cancel.cancel();
        assert!(closed(&mut socket), "cancelled shell kept running");
        let (error, ts) = finished.recv_timeout(Duration::from_secs(10)).unwrap();
        assert_eq!(error.code(), "CANCELLED_UNKNOWN");
        assert_eq!(
            std::fs::read_to_string(dir.join("made.txt")).unwrap(),
            "made"
        );
        let entry = ts.session.active_branch().entries().last().unwrap();
        let EntryContent::Meta { detail, .. } = ts.session.content(entry).unwrap() else {
            unreachable!()
        };
        let call_id =
            serde_json::from_value(serde_json::to_value(&detail).unwrap()["call_id"].clone())
                .unwrap();
        assert_eq!(
            ts.session.mutation_state(call_id).unwrap(),
            MutationState::Unknown
        );
    }

    #[test]
    fn failing_and_timing_out_commands_report_explicitly() {
        let dir = workdir("bash_failures");
        let mut ts = ToolSession::new(&dir);
        let error = ts
            .invoke("bash", &test_args!({"command": "exit 3"}))
            .unwrap_err();
        assert_eq!(error.code(), "BASH_EXIT");
        let error = ts
            .invoke(
                "bash",
                &test_args!({"command": "sleep 5", "timeout_ms": 150}),
            )
            .unwrap_err();
        assert_eq!(error.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 error = ts
            .invoke("write", &test_args!({"path": "never.txt", "content": "x"}))
            .unwrap_err();
        assert_eq!(error.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",
                &test_args!({"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 error = running.join().unwrap().unwrap_err();
        assert_eq!(error.code(), "CANCELLED_UNKNOWN");
    }

    #[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.frame_bytes_max = 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: a shell may have
        // mutated, but successful exit alone cannot prove it.
        let error = ts
            .invoke(
                "bash",
                &test_args!({"command": "printf made > actual.txt; printf 'x%.0s' $(seq 5000)"}),
            )
            .unwrap_err();
        assert_eq!(error.code(), "RECORD_FAILED");
        assert!(matches!(
            error,
            SmithError::RecordFailed {
                mutation_state: MutationState::Unknown,
                ..
            }
        ));
        assert_eq!(
            std::fs::read_to_string(dir.join("actual.txt")).unwrap(),
            "made"
        );
        let later = ts
            .invoke(
                "write",
                &test_args!({"path": "must-not-run.txt", "content": "x"}),
            )
            .unwrap_err();
        assert!(matches!(
            later,
            SmithError::RecordFailed {
                mutation_state: MutationState::Unknown,
                ..
            }
        ));
        assert!(!dir.join("must-not-run.txt").exists());
        // Only the durably recorded call is part of the live session.
        let kinds: Vec<EntryKind> = 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 error = ts
            .invoke("write", &test_args!({"path": "data.txt"}))
            .unwrap_err();
        assert_eq!(error.code(), "VALIDATION");
        assert_eq!(std::fs::read_to_string(&target).unwrap(), "original");
        let error = ts
            .invoke("write", &test_args!({"path": "data.txt", "content": 42}))
            .unwrap_err();
        assert_eq!(error.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",
            &test_args!({"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::<Vec<_>>()
            })
            .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 error = ts
            .invoke(
                "edit",
                &test_args!({"path": "f.txt", "old_string": "a", "new_string": "b"}),
            )
            .unwrap_err();
        assert_eq!(error.code(), "EDIT_AMBIGUOUS");
        assert_eq!(std::fs::read_to_string(&target).unwrap(), "a a");
        let error = ts
            .invoke(
                "edit",
                &test_args!({"path": "f.txt", "old_string": "NOT_PRESENT", "new_string": "b"}),
            )
            .unwrap_err();
        assert_eq!(error.code(), "EDIT_NOT_FOUND");
        assert_eq!(std::fs::read_to_string(&target).unwrap(), "a a");
        // Unambiguous edit succeeds.
        ts.invoke(
            "edit",
            &test_args!({"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",
                &test_args!({"path": "lines.txt", "offset": 2, "limit": 3}),
            )
            .unwrap();
        assert_eq!(out.output, "line-2\nline-3\nline-4\n");
        let capped = ts
            .invoke("read", &test_args!({"path": "lines.txt", "max_bytes": 20}))
            .unwrap();
        assert!(capped.output.len() <= 20);
        std::fs::write(dir.join("bin.dat"), [0x62, 0x00, 0x62]).unwrap();
        let error = ts
            .invoke("read", &test_args!({"path": "bin.dat"}))
            .unwrap_err();
        assert_eq!(error.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", &test_args!({})).unwrap();
        assert_eq!(out.output, "alpha\nmid\nzeta");
        let found = ts.invoke("find", &test_args!({"pattern": "a"})).unwrap();
        assert_eq!(found.output, "alpha\nzeta");
        let error = ts
            .invoke("find", &test_args!({"path": "missing-dir", "pattern": "x"}))
            .unwrap_err();
        assert_eq!(error.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", &test_args!({"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 error = ts.invoke("rm", &test_args!({})).unwrap_err();
        assert_eq!(error.code(), "TOOL_NOT_REGISTERED");
        let error = ts.invoke("read", &test_args!("not-an-object")).unwrap_err();
        assert_eq!(error.code(), "INVALID_INPUT");
        let _ = std::fs::remove_dir_all(&dir);
    }

    fn assert_recorded_failure(ts: &ToolSession) {
        let entries = ts.session.active_branch().entries();
        assert!(matches!(
            entries.iter().rev().nth(1).map(|entry| entry.kind),
            Some(EntryKind::ToolResult { ok: false, .. })
        ));
        assert!(matches!(
            entries.last().map(|entry| entry.kind),
            Some(EntryKind::Meta { .. })
        ));
    }

    fn cancel_at(
        dir: &Path,
        checkpoint: EffectCheckpoint,
        name: &str,
        input: serde_json::Value,
    ) -> (SmithError, ToolSession) {
        let mut ts = ToolSession::new(dir);
        ts.cancel_at_checkpoint(checkpoint);
        let error = ts
            .invoke(name, &ToolArguments::from_serializable(&input).unwrap())
            .unwrap_err();
        (error, ts)
    }

    fn timeout_at(
        dir: &Path,
        checkpoint: EffectCheckpoint,
        name: &str,
        input: serde_json::Value,
    ) -> (SmithError, ToolSession) {
        let mut ts = ToolSession::new(dir);
        ts.timeout_at_checkpoint(checkpoint);
        let error = ts
            .invoke(name, &ToolArguments::from_serializable(&input).unwrap())
            .unwrap_err();
        (error, ts)
    }

    #[test]
    fn cancellation_stops_file_effects_at_cooperative_boundaries() {
        let dir = workdir("file_cancellation");
        std::fs::write(dir.join("source.txt"), "needle\nold\n").unwrap();
        std::fs::create_dir_all(dir.join("nested")).unwrap();
        std::fs::write(dir.join("nested/child.txt"), "needle\n").unwrap();

        for (checkpoint, name, input) in [
            (
                EffectCheckpoint::ReadChunk,
                "read",
                serde_json::json!({"path": "source.txt"}),
            ),
            (
                EffectCheckpoint::GrepLine,
                "grep",
                serde_json::json!({"path": "source.txt", "query": "needle"}),
            ),
            (
                EffectCheckpoint::EditReadChunk,
                "edit",
                serde_json::json!({"path": "source.txt", "old_string": "old", "new_string": "new"}),
            ),
            (
                EffectCheckpoint::TraversalEntry,
                "find",
                serde_json::json!({"pattern": "child"}),
            ),
            (
                EffectCheckpoint::TraversalEntry,
                "grep",
                serde_json::json!({"path": "", "query": "needle"}),
            ),
        ] {
            let (error, ts) = cancel_at(&dir, checkpoint, name, input);
            assert_eq!(error.code(), "CANCELLED");
            assert_recorded_failure(&ts);
        }
        assert_eq!(
            std::fs::read_to_string(dir.join("source.txt")).unwrap(),
            "needle\nold\n"
        );
    }

    #[test]
    fn configured_timeout_stops_file_effects_at_cooperative_boundaries() {
        let dir = workdir("file_timeout_boundaries");
        std::fs::write(dir.join("source.txt"), "needle\nold\n").unwrap();
        std::fs::create_dir_all(dir.join("nested")).unwrap();
        std::fs::write(dir.join("nested/child.txt"), "needle\n").unwrap();

        for (checkpoint, name, input) in [
            (
                EffectCheckpoint::ReadChunk,
                "read",
                serde_json::json!({"path": "source.txt"}),
            ),
            (
                EffectCheckpoint::GrepLine,
                "grep",
                serde_json::json!({"path": "source.txt", "query": "needle"}),
            ),
            (
                EffectCheckpoint::EditReadChunk,
                "edit",
                serde_json::json!({"path": "source.txt", "old_string": "old", "new_string": "new"}),
            ),
            (
                EffectCheckpoint::WriteChunk,
                "write",
                serde_json::json!({"path": "new.txt", "content": "new"}),
            ),
            (
                EffectCheckpoint::TraversalEntry,
                "ls",
                serde_json::json!({}),
            ),
        ] {
            let (error, ts) = timeout_at(&dir, checkpoint, name, input);
            assert_eq!(error.code(), "TOOL_TIMEOUT");
            assert_recorded_failure(&ts);
        }
        assert_eq!(
            std::fs::read_to_string(dir.join("source.txt")).unwrap(),
            "needle\nold\n"
        );
    }

    #[test]
    fn cancellation_after_parent_creation_records_applied_directory_mutation() {
        let dir = workdir("write_parent_cancellation");
        let (error, ts) = cancel_at(
            &dir,
            EffectCheckpoint::ParentCreated,
            "write",
            serde_json::json!({"path": "a/b/target.txt", "content": "new"}),
        );
        assert_eq!(error.code(), "CANCELLED_APPLIED");
        assert!(dir.join("a/b").is_dir());
        assert!(!dir.join("a/b/target.txt").exists());
        assert!(matches!(
            ts.session.content(ts.session.active_branch().entries().last().unwrap()).unwrap(),
            EntryContent::Meta { kind, detail }
                if kind == "tool-mutation"
                    && serde_json::to_value(&detail).unwrap()["mutation_state"] == "applied"
        ));
    }

    #[test]
    fn deadline_after_parent_creation_records_applied_directory_mutation() {
        let dir = workdir("write_parent_deadline");
        let (error, ts) = timeout_at(
            &dir,
            EffectCheckpoint::ParentCreated,
            "write",
            serde_json::json!({"path": "a/b/target.txt", "content": "new"}),
        );
        assert_eq!(error.code(), "TOOL_TIMEOUT_APPLIED");
        assert!(dir.join("a/b").is_dir());
        assert!(!dir.join("a/b/target.txt").exists());
        let entry = ts.session.active_branch().entries().last().unwrap();
        assert!(matches!(
            ts.session.content(entry).unwrap(),
            EntryContent::Meta { detail, .. } if serde_json::to_value(&detail).unwrap()["mutation_state"] == "applied"
        ));
    }

    #[test]
    fn cancellation_before_or_after_atomic_publication_reports_truthfully() {
        let dir = workdir("write_commit_cancellation");
        let target = dir.join("target.txt");
        std::fs::write(&target, "old").unwrap();

        let (error, ts) = cancel_at(
            &dir,
            EffectCheckpoint::BeforePublish,
            "write",
            serde_json::json!({"path": "target.txt", "content": "new"}),
        );
        assert_eq!(error.code(), "CANCELLED");
        assert_eq!(std::fs::read_to_string(&target).unwrap(), "old");
        assert_recorded_failure(&ts);

        let (error, ts) = cancel_at(
            &dir,
            EffectCheckpoint::AfterPublish,
            "write",
            serde_json::json!({"path": "target.txt", "content": "new"}),
        );
        assert_eq!(error.code(), "CANCELLED_APPLIED");
        assert_eq!(std::fs::read_to_string(&target).unwrap(), "new");
        assert_recorded_failure(&ts);
    }

    #[test]
    fn unrecordable_post_publication_cancellation_reports_applied_mutation() {
        let dir = workdir("record_applied_cancellation");
        let target = dir.join("target.txt");
        std::fs::write(&target, "old").unwrap();
        let mut writes = 0usize;
        let mut ts = ToolSession::with_persist(&dir, Session::new(), move |_, _| {
            writes += 1;
            if writes == 1 {
                Ok(())
            } else {
                Err(tool_error(
                    smith::error::ToolCode::TestRecord,
                    "result persistence rejected",
                ))
            }
        });
        ts.cancel_at_checkpoint(EffectCheckpoint::AfterPublish);

        let error = ts
            .invoke(
                "write",
                &test_args!({"path": "target.txt", "content": "new"}),
            )
            .unwrap_err();
        assert!(matches!(
            error,
            SmithError::RecordFailed {
                mutation_state: MutationState::Applied,
                ..
            }
        ));
        assert_eq!(std::fs::read_to_string(&target).unwrap(), "new");
    }

    #[test]
    fn configured_deadline_stops_every_file_effect_before_it_starts() {
        let dir = workdir("file_deadline");
        std::fs::write(dir.join("source.txt"), "needle\nold\n").unwrap();
        std::fs::create_dir_all(dir.join("nested")).unwrap();
        std::fs::write(dir.join("nested/child.txt"), "needle\n").unwrap();
        let mut config = Config::default_valid();
        // Inject an already elapsed effect budget; no wall-clock wait.
        config.tool_timeout_ms = 0;
        let mut ts = ToolSession::new(&dir).with_config(config);

        for (name, input) in [
            ("read", serde_json::json!({"path": "source.txt"})),
            (
                "grep",
                serde_json::json!({"path": "source.txt", "query": "needle"}),
            ),
            ("grep", serde_json::json!({"path": "", "query": "needle"})),
            ("ls", serde_json::json!({})),
            ("find", serde_json::json!({"pattern": "child"})),
            (
                "write",
                serde_json::json!({"path": "source.txt", "content": "new"}),
            ),
            (
                "edit",
                serde_json::json!({"path": "source.txt", "old_string": "old", "new_string": "new"}),
            ),
        ] {
            let error = ts
                .invoke(name, &ToolArguments::from_serializable(&input).unwrap())
                .unwrap_err();
            assert_eq!(error.code(), "TOOL_TIMEOUT");
        }
        assert_eq!(
            std::fs::read_to_string(dir.join("source.txt")).unwrap(),
            "needle\nold\n"
        );
        assert_eq!(
            ts.session
                .active_branch()
                .entries()
                .iter()
                .filter(|entry| matches!(entry.kind, EntryKind::ToolResult { ok: false, .. }))
                .count(),
            7
        );
    }

    #[test]
    fn failed_effects_are_recorded_as_failures() {
        let dir = workdir("record_fail");
        let mut ts = ToolSession::new(&dir);
        let res = ts.invoke("read", &test_args!({"path": "nope.txt"}));
        assert!(res.is_err());
        // Call, failed result, and typed mutation completion are recorded.
        assert_eq!(ts.session.active_branch().entries().len(), 3);
        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);
    }
}