Skip to main content

winx_code_agent/tools/edit_files/
wire.rs

1use schemars::JsonSchema;
2use serde::Deserialize;
3use serde_json::Value;
4
5use super::domain::{
6    validate_aggregate, EditChange, EditCommand, EditMode, EditOperation, EditSurface,
7    EditVerification, NormalizedEditCall,
8};
9use crate::errors::{Result, WinxError};
10use crate::tools::file_write_or_edit::uses_search_replace;
11use crate::types::{ApplyPatch, FileWriteOrEdit, LinePatch, MultiFileEdit, UndoEdit};
12
13#[derive(Clone, Debug, Deserialize, JsonSchema)]
14#[serde(deny_unknown_fields)]
15pub struct EditFilesWire {
16    /// Optional explicit operation. Omit it for normal edits; exactly one `mode=undo`
17    /// entry infers `undo`. An explicit value must agree with the listed modes.
18    /// `release_temporary` must be explicit and contain only exact managed helper
19    /// targets observed by `ReadFiles`; requires a capable idle runtime owner.
20    #[serde(default)]
21    pub operation: Option<EditOperation>,
22    /// One unique target per entry. All apply entries are validated before writing begins.
23    #[schemars(length(min = 1, max = 100))]
24    pub files: Vec<EditFileWire>,
25    /// Optional finite foreground check run once after a successful apply.
26    #[serde(default)]
27    pub verify_command: Option<String>,
28    /// Seconds to wait inline for `verify_command`, from 0 through 60.
29    #[serde(default)]
30    pub verify_wait_for_seconds: Option<f32>,
31    /// Exact project-session identifier returned by `Initialize`.
32    pub thread_id: String,
33    /// Exact project identity returned alongside `thread_id` by `Initialize`.
34    #[serde(default)]
35    pub workspace_root: Option<String>,
36}
37
38#[derive(Clone, Debug, Deserialize, JsonSchema)]
39#[serde(deny_unknown_fields)]
40pub struct EditFileWire {
41    /// Target path. Relative paths resolve from the session's current shell directory.
42    pub file_path: String,
43    /// `line_patch` is the default for an existing file after `ReadFiles`; copy its
44    /// revision and visible coordinates. Reserve `search_replace` for intentional
45    /// text anchoring copied exactly from the current read, use `replace` for new
46    /// files or deliberate whole-file rewrites, and `undo` only as the sole entry
47    /// with its exact `undo_id`. `release_temporary` is valid only with the same
48    /// explicit operation and a fresh revision plus artifact generation.
49    pub mode: EditMode,
50    /// Required by `replace` and `search_replace`; forbidden by other modes. For
51    /// `search_replace`, use one or more exact
52    /// `<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE` blocks. An optional
53    /// `@line` or `@start-end` may follow `SEARCH` to disambiguate repeated text.
54    #[serde(default)]
55    pub content: Option<String>,
56    /// Exact `ReadFiles.files[].revision`; required by `line_patch` and
57    /// `release_temporary`.
58    #[serde(default)]
59    pub expected_revision: Option<String>,
60    /// Ordered, non-overlapping, one-based changes; required only by `line_patch`.
61    #[serde(default)]
62    pub patches: Option<Vec<LinePatch>>,
63    /// Exact receipt returned by the edit being undone; required only by `undo`.
64    #[serde(default)]
65    pub undo_id: Option<String>,
66    /// Exact `ReadFiles` temporary-artifact generation, required only for an
67    /// explicit `operation=release_temporary`; it is not a general deletion token.
68    #[serde(default)]
69    pub artifact_generation: Option<String>,
70}
71
72pub fn normalize_edit_call(surface: EditSurface, arguments: Value) -> Result<NormalizedEditCall> {
73    let original_arguments = arguments.clone();
74    if surface == EditSurface::EditFiles {
75        let request: EditFilesWire = deserialize(arguments, surface.tool_name())?;
76        return normalize_new_wire(request, original_arguments);
77    }
78    let workspace_root =
79        arguments.get("workspace_root").and_then(Value::as_str).map(str::to_string);
80    let verification = parse_verification(&arguments)?;
81    let mut domain_arguments = arguments;
82    if let Some(map) = domain_arguments.as_object_mut() {
83        map.remove("workspace_root");
84        map.remove("verify_command");
85        map.remove("verify_wait_for_seconds");
86    }
87
88    let (surface, thread_id, command) =
89        normalize_legacy_wire(surface, domain_arguments, verification.is_some())?;
90    let changes = match &command {
91        EditCommand::Apply { changes } => changes.as_slice(),
92        EditCommand::Undo { .. } | EditCommand::ReleaseTemporary { .. } => &[],
93    };
94    validate_aggregate(changes)?;
95    Ok(NormalizedEditCall {
96        surface,
97        command,
98        verification,
99        thread_id,
100        workspace_root,
101        original_arguments,
102    })
103}
104
105fn normalize_legacy_wire(
106    surface: EditSurface,
107    arguments: Value,
108    has_verification: bool,
109) -> Result<(EditSurface, String, EditCommand)> {
110    Ok(match surface {
111        EditSurface::FileWriteOrEdit => {
112            let request: FileWriteOrEdit = deserialize(arguments, surface.tool_name())?;
113            let mode = if uses_search_replace(
114                request.percentage_to_change,
115                &request.text_or_search_replace_blocks,
116            ) {
117                EditMode::SearchReplace
118            } else {
119                EditMode::Replace
120            };
121            let change =
122                text_change(mode, request.file_path, request.text_or_search_replace_blocks)?;
123            (
124                EditSurface::FileWriteOrEdit,
125                request.thread_id,
126                EditCommand::Apply { changes: vec![change] },
127            )
128        }
129        EditSurface::MultiFileEdit => {
130            let request: MultiFileEdit = deserialize(arguments, surface.tool_name())?;
131            if request.files.len() < 2 {
132                return Err(WinxError::ArgumentParseError(
133                    "MultiFileEdit needs at least 2 files; use FileWriteOrEdit for a single file."
134                        .to_string(),
135                ));
136            }
137            if request.files.len() > 100 {
138                return Err(WinxError::ArgumentParseError(format!(
139                    "MultiFileEdit is limited to 100 files per batch (got {}); split the change into smaller batches.",
140                    request.files.len()
141                )));
142            }
143            let changes = request
144                .files
145                .into_iter()
146                .map(|entry| {
147                    let mode = if uses_search_replace(
148                        entry.percentage_to_change,
149                        &entry.text_or_search_replace_blocks,
150                    ) {
151                        EditMode::SearchReplace
152                    } else {
153                        EditMode::Replace
154                    };
155                    text_change(mode, entry.file_path, entry.text_or_search_replace_blocks)
156                })
157                .collect::<Result<Vec<_>>>()?;
158            (EditSurface::MultiFileEdit, request.thread_id, EditCommand::Apply { changes })
159        }
160        EditSurface::ApplyPatch => {
161            let request: ApplyPatch = deserialize(arguments, surface.tool_name())?;
162            crate::tools::apply_patch::validate_revision(&request.expected_revision)?;
163            crate::tools::apply_patch::validate_patch_count(&request.patches)?;
164            let change = EditChange::LinePatch {
165                file_path: request.file_path,
166                expected_revision: request.expected_revision,
167                patches: request.patches,
168            };
169            (
170                EditSurface::ApplyPatch,
171                request.thread_id,
172                EditCommand::Apply { changes: vec![change] },
173            )
174        }
175        EditSurface::UndoEdit => {
176            if has_verification {
177                return Err(WinxError::InvalidInput(
178                    "UndoEdit does not accept verification fields".to_string(),
179                ));
180            }
181            let request: UndoEdit = deserialize(arguments, surface.tool_name())?;
182            (
183                EditSurface::UndoEdit,
184                request.thread_id,
185                EditCommand::Undo {
186                    file_path: request.file_path,
187                    undo_id: None,
188                    legacy_lifo: true,
189                },
190            )
191        }
192        EditSurface::EditFiles => unreachable!("EditFiles is normalized before legacy lowering"),
193    })
194}
195
196fn normalize_new_wire(
197    request: EditFilesWire,
198    original_arguments: Value,
199) -> Result<NormalizedEditCall> {
200    if request.files.is_empty() || request.files.len() > 100 {
201        return Err(WinxError::InvalidInput(
202            "EditFiles files must contain between 1 and 100 entries".to_string(),
203        ));
204    }
205    let verification =
206        parse_verification_fields(request.verify_command, request.verify_wait_for_seconds)?;
207    let operation = request.operation.unwrap_or_else(|| {
208        if request.files.len() == 1 && request.files[0].mode == EditMode::Undo {
209            EditOperation::Undo
210        } else {
211            EditOperation::Apply
212        }
213    });
214    let command = match operation {
215        EditOperation::ReleaseTemporary => {
216            if request.workspace_root.as_deref().is_none_or(str::is_empty) || verification.is_some()
217            {
218                return Err(WinxError::InvalidInput(
219                    "release_temporary requires workspace_root and forbids verification".into(),
220                ));
221            }
222            release_wire_command(request.files)?
223        }
224        EditOperation::Apply => {
225            let changes = request
226                .files
227                .into_iter()
228                .enumerate()
229                .map(|(index, file)| apply_wire_change(index, file))
230                .collect::<Result<Vec<_>>>()?;
231            validate_aggregate(&changes)?;
232            EditCommand::Apply { changes }
233        }
234        EditOperation::Undo => {
235            if verification.is_some() {
236                return Err(WinxError::InvalidInput(
237                    "EditFiles verification fields are valid only for operation=apply".to_string(),
238                ));
239            }
240            if request.files.len() != 1 {
241                return Err(WinxError::InvalidInput(
242                    "EditFiles operation=undo requires exactly one file".to_string(),
243                ));
244            }
245            let file = request.files.into_iter().next().ok_or_else(|| {
246                WinxError::InvalidInput("EditFiles undo target is missing".to_string())
247            })?;
248            if file.mode != EditMode::Undo {
249                return Err(WinxError::InvalidInput(
250                    "EditFiles operation=undo requires files[0].mode=undo".to_string(),
251                ));
252            }
253            reject_payload_fields(&file, "undo")?;
254            let undo_id = file.undo_id.filter(|id| !id.trim().is_empty()).ok_or_else(|| {
255                WinxError::InvalidInput(
256                    "EditFiles operation=undo requires the exact non-empty undo_id returned by the edit"
257                        .to_string(),
258                )
259            })?;
260            EditCommand::Undo {
261                file_path: file.file_path,
262                undo_id: Some(undo_id),
263                legacy_lifo: false,
264            }
265        }
266    };
267    Ok(NormalizedEditCall {
268        surface: EditSurface::EditFiles,
269        command,
270        verification,
271        thread_id: request.thread_id,
272        workspace_root: request.workspace_root,
273        original_arguments,
274    })
275}
276
277fn release_wire_command(files: Vec<EditFileWire>) -> Result<EditCommand> {
278    let mut targets = Vec::with_capacity(files.len());
279    for file in files {
280        if file.mode != EditMode::ReleaseTemporary
281            || file.content.is_some()
282            || file.patches.is_some()
283            || file.undo_id.is_some()
284        {
285            return Err(WinxError::InvalidInput(
286                "release_temporary accepts only exact revision and generation targets".into(),
287            ));
288        }
289        let expected_revision = file.expected_revision.ok_or_else(|| {
290            WinxError::InvalidInput("release_temporary requires expected_revision".into())
291        })?;
292        crate::tools::apply_patch::validate_revision(&expected_revision)?;
293        let artifact_generation = file
294            .artifact_generation
295            .filter(|value| !value.is_empty() && value.len() <= 256)
296            .ok_or_else(|| {
297                WinxError::InvalidInput(
298                    "release_temporary requires artifact_generation from ReadFiles".into(),
299                )
300            })?;
301        targets.push(crate::utils::agent_temp::release::TemporaryReleaseTarget {
302            file_path: file.file_path,
303            expected_revision,
304            artifact_generation,
305        });
306    }
307    Ok(EditCommand::ReleaseTemporary { files: targets })
308}
309
310fn apply_wire_change(index: usize, file: EditFileWire) -> Result<EditChange> {
311    if file.artifact_generation.is_some() || file.mode == EditMode::ReleaseTemporary {
312        return Err(WinxError::InvalidInput(
313            "temporary generation and mode require explicit operation=release_temporary".into(),
314        ));
315    }
316    if file.mode == EditMode::Undo {
317        return Err(WinxError::InvalidInput(format!(
318            "files[{index}].mode=undo requires operation=undo and cannot be mixed with apply"
319        )));
320    }
321    if file.undo_id.is_some() {
322        return Err(WinxError::InvalidInput(format!(
323            "files[{index}].undo_id is valid only for mode=undo"
324        )));
325    }
326    match file.mode {
327        EditMode::Replace | EditMode::SearchReplace => {
328            if file.expected_revision.is_some() || file.patches.is_some() {
329                return Err(WinxError::InvalidInput(format!(
330                    "files[{index}] text modes accept content only"
331                )));
332            }
333            let content = file.content.ok_or_else(|| {
334                WinxError::InvalidInput(format!("files[{index}].content is required"))
335            })?;
336            text_change(file.mode, file.file_path, content)
337        }
338        EditMode::LinePatch => {
339            if file.content.is_some() {
340                return Err(WinxError::InvalidInput(format!(
341                    "files[{index}].content is not valid for line_patch"
342                )));
343            }
344            let expected_revision = file.expected_revision.ok_or_else(|| {
345                WinxError::InvalidInput(format!(
346                    "files[{index}].expected_revision is required for line_patch"
347                ))
348            })?;
349            let patches = file.patches.ok_or_else(|| {
350                WinxError::InvalidInput(format!(
351                    "files[{index}].patches is required for line_patch"
352                ))
353            })?;
354            crate::tools::apply_patch::validate_revision(&expected_revision)?;
355            crate::tools::apply_patch::validate_patch_count(&patches)?;
356            Ok(EditChange::LinePatch { file_path: file.file_path, expected_revision, patches })
357        }
358        EditMode::ReleaseTemporary => unreachable!("release handled separately"),
359        EditMode::Undo => unreachable!("handled above"),
360    }
361}
362
363fn reject_payload_fields(file: &EditFileWire, mode: &str) -> Result<()> {
364    if file.artifact_generation.is_some() {
365        return Err(WinxError::InvalidInput(
366            "artifact_generation is valid only for release_temporary".into(),
367        ));
368    }
369    if file.content.is_some() || file.expected_revision.is_some() || file.patches.is_some() {
370        return Err(WinxError::InvalidInput(format!(
371            "mode={mode} does not accept content, expected_revision, or patches"
372        )));
373    }
374    Ok(())
375}
376
377fn text_change(mode: EditMode, file_path: String, content: String) -> Result<EditChange> {
378    match mode {
379        EditMode::Replace => Ok(EditChange::Replace { file_path, content }),
380        EditMode::SearchReplace => Ok(EditChange::SearchReplace { file_path, content }),
381        _ => Err(WinxError::InvalidInput("invalid text edit mode".to_string())),
382    }
383}
384
385fn parse_verification(arguments: &Value) -> Result<Option<EditVerification>> {
386    let command = arguments
387        .get("verify_command")
388        .filter(|value| !value.is_null())
389        .map(|value| {
390            value.as_str().map(str::to_string).ok_or_else(|| {
391                WinxError::InvalidInput("verify_command must be a string".to_string())
392            })
393        })
394        .transpose()?;
395    let wait = arguments
396        .get("verify_wait_for_seconds")
397        .filter(|value| !value.is_null())
398        .map(|value| {
399            serde_json::from_value::<f32>(value.clone()).map_err(|error| {
400                WinxError::InvalidInput(format!("invalid verify_wait_for_seconds: {error}"))
401            })
402        })
403        .transpose()?;
404    parse_verification_fields(command, wait)
405}
406
407fn parse_verification_fields(
408    command: Option<String>,
409    wait_for_seconds: Option<f32>,
410) -> Result<Option<EditVerification>> {
411    let command = command.map(|command| command.trim().to_string());
412    match command {
413        Some(command) if command.is_empty() => {
414            Err(WinxError::InvalidInput("verify_command must not be empty".to_string()))
415        }
416        Some(_command)
417            if wait_for_seconds
418                .is_some_and(|wait| !wait.is_finite() || !(0.0..=60.0).contains(&wait)) =>
419        {
420            Err(WinxError::InvalidInput(
421                "verify_wait_for_seconds must be between 0 and 60".to_string(),
422            ))
423        }
424        Some(command) => Ok(Some(EditVerification { command, wait_for_seconds })),
425        None if wait_for_seconds.is_some() => Err(WinxError::InvalidInput(
426            "verify_wait_for_seconds requires verify_command".to_string(),
427        )),
428        None => Ok(None),
429    }
430}
431
432fn deserialize<T: serde::de::DeserializeOwned>(value: Value, tool: &str) -> Result<T> {
433    serde_json::from_value(value)
434        .map_err(|error| WinxError::InvalidInput(format!("Invalid {tool} parameters: {error}")))
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn unified_wire_keeps_explicit_workspace_and_verification_fields() -> Result<()> {
443        let normalized = normalize_edit_call(
444            EditSurface::EditFiles,
445            serde_json::json!({
446                "operation": "apply",
447                "files": [{
448                    "file_path": "/workspace/file.rs",
449                    "mode": "replace",
450                    "content": "fn main() {}"
451                }],
452                "verify_command": " cargo check ",
453                "verify_wait_for_seconds": 12.5,
454                "thread_id": "thread",
455                "workspace_root": "/workspace"
456            }),
457        )?;
458        assert_eq!(normalized.workspace_root.as_deref(), Some("/workspace"));
459        let verification = normalized.verification.ok_or_else(|| {
460            WinxError::InvalidInput("expected normalized verification".to_string())
461        })?;
462        assert_eq!(verification.command, "cargo check");
463        assert_eq!(verification.wait_for_seconds, Some(12.5));
464        Ok(())
465    }
466
467    #[test]
468    fn unified_wire_rejects_unknown_entry_fields_and_mixed_undo() {
469        let unknown = serde_json::json!({
470            "operation": "apply",
471            "files": [{
472                "file_path": "/workspace/file.rs",
473                "mode": "replace",
474                "content": "content",
475                "percentage_to_change": 100
476            }],
477            "thread_id": "thread"
478        });
479        assert!(normalize_edit_call(EditSurface::EditFiles, unknown).is_err());
480
481        let mixed = serde_json::json!({
482            "operation": "apply",
483            "files": [{
484                "file_path": "/workspace/file.rs",
485                "mode": "undo",
486                "undo_id": "undo_123"
487            }],
488            "thread_id": "thread"
489        });
490        assert!(normalize_edit_call(EditSurface::EditFiles, mixed).is_err());
491    }
492
493    #[test]
494    fn unified_wire_infers_common_apply_and_single_undo_operations() -> Result<()> {
495        let apply = normalize_edit_call(
496            EditSurface::EditFiles,
497            serde_json::json!({
498                "files": [{
499                    "file_path": "/workspace/new.rs",
500                    "mode": "replace",
501                    "content": "fn main() {}"
502                }],
503                "thread_id": "thread"
504            }),
505        )?;
506        assert_eq!(apply.command.operation(), EditOperation::Apply);
507
508        let undo = normalize_edit_call(
509            EditSurface::EditFiles,
510            serde_json::json!({
511                "files": [{
512                    "file_path": "/workspace/new.rs",
513                    "mode": "undo",
514                    "undo_id": "undo_123"
515                }],
516                "thread_id": "thread"
517            }),
518        )?;
519        assert_eq!(undo.command.operation(), EditOperation::Undo);
520        Ok(())
521    }
522
523    #[test]
524    fn legacy_marker_heuristic_does_not_leak_into_explicit_modes() -> Result<()> {
525        let content = "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE\n";
526        let legacy = normalize_edit_call(
527            EditSurface::FileWriteOrEdit,
528            serde_json::json!({
529                "file_path": "/workspace/file.rs",
530                "percentage_to_change": 100,
531                "text_or_search_replace_blocks": content,
532                "thread_id": "thread"
533            }),
534        )?;
535        let explicit = normalize_edit_call(
536            EditSurface::EditFiles,
537            serde_json::json!({
538                "operation": "apply",
539                "files": [{
540                    "file_path": "/workspace/file.rs",
541                    "mode": "replace",
542                    "content": content
543                }],
544                "thread_id": "thread"
545            }),
546        )?;
547        assert_eq!(legacy.command.modes(), vec![EditMode::SearchReplace]);
548        assert_eq!(explicit.command.modes(), vec![EditMode::Replace]);
549        Ok(())
550    }
551}
552
553#[cfg(test)]
554mod temporary_release_contract_tests {
555    #![allow(clippy::unwrap_used, clippy::expect_used)]
556    use super::*;
557    use serde_json::json;
558
559    fn release_request() -> Value {
560        json!({
561            "thread_id": "initialized-thread",
562            "workspace_root": "/initialized/workspace",
563            "operation": "release_temporary",
564            "files": [{
565                "file_path": "/initialized/workspace/.winx/tmp/session/helper.py",
566                "mode": "release_temporary",
567                "expected_revision": format!("sha256:{}", "a".repeat(64)),
568                "artifact_generation": "opaque-observed-generation"
569            }]
570        })
571    }
572
573    #[test]
574    fn explicit_temporary_release_normalizes_without_verification_or_edit_payload() {
575        let normalized = normalize_edit_call(EditSurface::EditFiles, release_request())
576            .expect("the explicit revision-and-generation release contract must normalize");
577        assert_eq!(normalized.command.operation().as_str(), "release_temporary");
578        assert_eq!(normalized.command.modes()[0].as_str(), "release_temporary");
579        assert_eq!(normalized.command.file_count(), 1);
580        assert!(normalized.verification.is_none());
581    }
582
583    #[test]
584    fn temporary_release_requires_explicit_operation_workspace_revision_and_generation() {
585        for field in ["operation", "workspace_root"] {
586            let mut request = release_request();
587            request.as_object_mut().unwrap().remove(field);
588            assert!(normalize_edit_call(EditSurface::EditFiles, request).is_err(), "{field}");
589        }
590        for field in ["expected_revision", "artifact_generation"] {
591            for replacement in [None, Some(json!("")), Some(Value::Null)] {
592                let mut request = release_request();
593                let file = request["files"][0].as_object_mut().unwrap();
594                if let Some(value) = replacement {
595                    file.insert(field.into(), value);
596                } else {
597                    file.remove(field);
598                }
599                assert!(normalize_edit_call(EditSurface::EditFiles, request).is_err(), "{field}");
600            }
601        }
602    }
603
604    #[test]
605    fn temporary_release_rejects_edit_and_verification_payloads() {
606        for (field, value) in [
607            ("content", json!("must not write")),
608            ("patches", json!([])),
609            ("undo_id", json!("old-checkpoint")),
610        ] {
611            let mut request = release_request();
612            request["files"][0][field] = value;
613            assert!(normalize_edit_call(EditSurface::EditFiles, request).is_err(), "{field}");
614        }
615        for (field, value) in
616            [("verify_command", json!("must-not-execute")), ("verify_wait_for_seconds", json!(0))]
617        {
618            let mut request = release_request();
619            request[field] = value;
620            assert!(normalize_edit_call(EditSurface::EditFiles, request).is_err(), "{field}");
621        }
622        let mut mixed = release_request();
623        mixed["files"].as_array_mut().unwrap().push(json!({
624            "file_path": "/initialized/workspace/source.rs", "mode": "replace", "content": ""
625        }));
626        assert!(normalize_edit_call(EditSurface::EditFiles, mixed).is_err());
627    }
628
629    #[test]
630    fn temporary_generation_is_rejected_for_normal_edits() {
631        let request = json!({
632            "thread_id": "initialized-thread",
633            "files": [{"file_path": "source.rs", "mode": "replace", "content": "source",
634                "artifact_generation": "not-an-edit-field"}]
635        });
636        assert!(normalize_edit_call(EditSurface::EditFiles, request).is_err());
637    }
638}