winx-code-agent 0.2.351

Remote-first Rust MCP runtime with Streamable HTTP, durable PTYs, and guarded agent tools
Documentation
//! Revision-guarded, line-oriented file patches.

use std::sync::Arc;

use tokio::sync::Mutex;
use tracing::instrument;

use crate::errors::{Result, WinxError};
use crate::state::bash_state::BashState;
use crate::types::{ApplyPatch, LinePatch};

const MAX_PATCHES: usize = 256;

#[derive(Debug)]
pub struct ApplyPatchOutcome {
    pub text: String,
    pub revision: String,
}

#[instrument(level = "info", skip(bash_state_arc, request))]
pub async fn handle_tool_call(
    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
    request: ApplyPatch,
) -> Result<ApplyPatchOutcome> {
    let outcome = crate::tools::edit_files::handle_legacy_tool(
        bash_state_arc,
        crate::tools::edit_files::EditSurface::ApplyPatch,
        request,
    )
    .await?;
    let revision = outcome.revisions.into_iter().next().ok_or_else(|| {
        WinxError::CommandExecutionError(
            "ApplyPatch unified engine returned no committed revision".to_string(),
        )
    })?;
    Ok(ApplyPatchOutcome { text: outcome.text, revision })
}

pub(crate) fn validate_revision(revision: &str) -> Result<()> {
    let hash = revision.strip_prefix("sha256:").unwrap_or_default();
    if hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) {
        return Err(WinxError::ParameterValidationError {
            field: "expected_revision".to_string(),
            message: "must be the exact sha256:<64 lowercase hex> token returned by ReadFiles"
                .to_string(),
        });
    }
    if hash.bytes().any(|byte| byte.is_ascii_uppercase()) {
        return Err(WinxError::ParameterValidationError {
            field: "expected_revision".to_string(),
            message: "revision tokens are case-sensitive; copy the ReadFiles value exactly"
                .to_string(),
        });
    }
    Ok(())
}

pub(crate) fn validate_patch_count(patches: &[LinePatch]) -> Result<()> {
    if patches.is_empty() || patches.len() > MAX_PATCHES {
        return Err(WinxError::ParameterValidationError {
            field: "patches".to_string(),
            message: format!("must contain between 1 and {MAX_PATCHES} patches"),
        });
    }
    Ok(())
}

pub(crate) fn required_read_ranges(patches: &[LinePatch]) -> Vec<(usize, usize)> {
    patches
        .iter()
        .map(|patch| {
            if patch.delete_lines == 0 {
                (patch.start_line, patch.start_line)
            } else {
                (
                    patch.start_line,
                    patch.start_line.saturating_add(patch.delete_lines).saturating_sub(1),
                )
            }
        })
        .collect()
}

pub(crate) fn apply_line_patches(content: &str, patches: &[LinePatch]) -> Result<String> {
    let spans = line_spans(content);
    validate_patch_coordinates(patches, spans.len())?;
    let mut output = content.to_string();
    for patch in patches.iter().rev() {
        let start_index = patch.start_line - 1;
        let byte_start = spans.get(start_index).map_or(content.len(), |(start, _)| *start);
        let byte_end = if patch.delete_lines == 0 {
            byte_start
        } else {
            spans[start_index + patch.delete_lines - 1].1
        };
        output.replace_range(byte_start..byte_end, &patch.replacement);
    }
    Ok(output)
}

fn validate_patch_coordinates(patches: &[LinePatch], total_lines: usize) -> Result<()> {
    let mut previous_start = 0;
    let mut previous_end = 0;
    for (index, patch) in patches.iter().enumerate() {
        if patch.start_line == 0 || patch.start_line > total_lines.saturating_add(1) {
            return invalid_patch(index, format!("start_line must be in 1..={}", total_lines + 1));
        }
        let start = patch.start_line - 1;
        let end = start.checked_add(patch.delete_lines).ok_or_else(|| {
            WinxError::ParameterValidationError {
                field: format!("patches[{index}].delete_lines"),
                message: "line range overflow".to_string(),
            }
        })?;
        if end > total_lines {
            return invalid_patch(index, "delete_lines extends past the original file".to_string());
        }
        if index > 0 && (start < previous_end || start == previous_start) {
            return invalid_patch(
                index,
                "patches must be strictly ordered and non-overlapping in the original revision"
                    .to_string(),
            );
        }
        previous_start = start;
        previous_end = end;
    }
    Ok(())
}

fn invalid_patch<T>(index: usize, message: String) -> Result<T> {
    Err(WinxError::ParameterValidationError { field: format!("patches[{index}]"), message })
}

fn line_spans(content: &str) -> Vec<(usize, usize)> {
    let mut spans = Vec::new();
    let mut start = 0;
    for (index, byte) in content.bytes().enumerate() {
        if byte == b'\n' {
            spans.push((start, index + 1));
            start = index + 1;
        }
    }
    if start < content.len() {
        spans.push((start, content.len()));
    }
    spans
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use super::*;
    use tokio::sync::Mutex;

    use crate::errors::ReadRequirement;
    use crate::types::{normalize_thread_id, Modes, ReadFiles};

    fn patch(start_line: usize, delete_lines: usize, replacement: &str) -> LinePatch {
        LinePatch { start_line, delete_lines, replacement: replacement.to_string() }
    }

    #[test]
    fn applies_multiple_original_revision_patches_from_the_end() -> Result<()> {
        let output = apply_line_patches(
            "one\ntwo\nthree\nfour\n",
            &[patch(2, 1, "TWO\n"), patch(4, 0, "before four\n")],
        )?;
        assert_eq!(output, "one\nTWO\nthree\nbefore four\nfour\n");
        Ok(())
    }

    #[test]
    fn supports_append_and_empty_file_insert() -> Result<()> {
        assert_eq!(apply_line_patches("one\n", &[patch(2, 0, "two\n")])?, "one\ntwo\n");
        assert_eq!(apply_line_patches("", &[patch(1, 0, "first\n")])?, "first\n");
        Ok(())
    }

    #[test]
    fn rejects_overlapping_or_out_of_order_coordinates() {
        assert!(apply_line_patches("one\ntwo\nthree\n", &[patch(2, 2, "x\n"), patch(3, 1, "y\n")])
            .is_err());
        assert!(apply_line_patches("one\n", &[patch(3, 0, "x")]).is_err());
    }

    fn state_for(root: &std::path::Path, thread_id: &str) -> Arc<Mutex<Option<BashState>>> {
        let mut state = BashState::new();
        state.initialized = true;
        state.cwd = root.to_path_buf();
        state.workspace_root = root.to_path_buf();
        state.current_thread_id = normalize_thread_id(thread_id);
        state.mode = Modes::Wcgw;
        Arc::new(Mutex::new(Some(state)))
    }

    #[tokio::test]
    async fn revision_patch_uses_only_visible_lines_and_stale_retry_is_safe() -> Result<()> {
        let temp = tempfile::tempdir()?;
        let path = temp.path().join("sample.txt");
        std::fs::write(&path, "one\ntwo\nthree\n")?;
        let state = state_for(temp.path(), "patch-test");
        let read = crate::tools::read_files::handle_tool_call_detailed(
            &state,
            ReadFiles {
                file_paths: vec![path.to_string_lossy().into_owned()],
                thread_id: "patch-test".to_string(),
                start_line_nums: vec![Some(2)],
                end_line_nums: vec![Some(2)],
            },
        )
        .await?;
        let revision = read.files[0].revision.clone();

        let outcome = handle_tool_call(
            &state,
            ApplyPatch {
                file_path: path.to_string_lossy().into_owned(),
                expected_revision: revision.clone(),
                patches: vec![patch(2, 1, "TWO\n")],
                thread_id: "patch-test".to_string(),
            },
        )
        .await?;
        assert_ne!(outcome.revision, revision);
        assert_eq!(std::fs::read_to_string(&path)?, "one\nTWO\nthree\n");
        assert!(state
            .lock()
            .await
            .as_ref()
            .is_some_and(|state| state.whitelist_for_overwrite.is_empty()));

        let stale_result = handle_tool_call(
            &state,
            ApplyPatch {
                file_path: path.to_string_lossy().into_owned(),
                expected_revision: revision,
                patches: vec![patch(2, 1, "WRONG\n")],
                thread_id: "patch-test".to_string(),
            },
        )
        .await;
        assert!(matches!(stale_result, Err(WinxError::FileRevisionMismatch { .. })));
        assert_eq!(std::fs::read_to_string(&path)?, "one\nTWO\nthree\n");
        Ok(())
    }

    #[tokio::test]
    async fn patch_cannot_touch_an_unread_line() -> Result<()> {
        let temp = tempfile::tempdir()?;
        let path = temp.path().join("sample.txt");
        std::fs::write(&path, "one\ntwo\nthree\n")?;
        let state = state_for(temp.path(), "coverage-test");
        let read = crate::tools::read_files::handle_tool_call_detailed(
            &state,
            ReadFiles {
                file_paths: vec![path.to_string_lossy().into_owned()],
                thread_id: "coverage-test".to_string(),
                start_line_nums: vec![Some(2)],
                end_line_nums: vec![Some(2)],
            },
        )
        .await?;
        let patch_result = handle_tool_call(
            &state,
            ApplyPatch {
                file_path: path.to_string_lossy().into_owned(),
                expected_revision: read.files[0].revision.clone(),
                patches: vec![patch(3, 1, "THREE\n")],
                thread_id: "coverage-test".to_string(),
            },
        )
        .await;
        assert!(matches!(
            patch_result,
            Err(WinxError::FileReadRequired { reason: ReadRequirement::InsufficientCoverage, .. })
        ));
        assert_eq!(std::fs::read_to_string(path)?, "one\ntwo\nthree\n");
        Ok(())
    }
}