diff --git a/src/cycod/FunctionCalling/FunctionFactory.cs b/src/cycod/FunctionCalling/FunctionFactory.cs index 18bdeaa6..877c6f03 100644 --- a/src/cycod/FunctionCalling/FunctionFactory.cs +++ b/src/cycod/FunctionCalling/FunctionFactory.cs @@ -318,7 +318,8 @@ private static object CreateGenericCollectionFromJsonArray(string parameterValue var collection = Array.CreateInstance(elementType, array.Length); for (int i = 0; i < array.Length; i++) { - var parsed = ParseParameterValue(array[i].GetRawText(), elementType); + var elementValue = array[i].ValueKind == JsonValueKind.String ? array[i].GetString()! : array[i].GetRawText(); + var parsed = ParseParameterValue(elementValue, elementType); if (parsed != null) collection.SetValue(parsed, i); } return collection; @@ -329,7 +330,8 @@ private static object CreateGenericCollectionFromJsonArray(string parameterValue var list = collection as IList; foreach (var item in array) { - var parsed = ParseParameterValue(item.GetRawText(), elementType); + var elementValue = item.ValueKind == JsonValueKind.String ? item.GetString()! : item.GetRawText(); + var parsed = ParseParameterValue(elementValue, elementType); if (parsed != null) list!.Add(parsed); } return collection!; @@ -349,7 +351,8 @@ private static object CreateTuppleTypeFromJsonArray(string parameterValue, Type foreach (var item in array) { - var parsed = ParseParameterValue(item.GetRawText(), elementType); + var elementValue = item.ValueKind == JsonValueKind.String ? item.GetString()! : item.GetRawText(); + var parsed = ParseParameterValue(elementValue, elementType); if (parsed != null) list!.Add(parsed); } diff --git a/src/cycod/FunctionCallingTools/StrReplaceEditorHelperFunctions.cs b/src/cycod/FunctionCallingTools/StrReplaceEditorHelperFunctions.cs index be8bab2e..a32b5602 100644 --- a/src/cycod/FunctionCallingTools/StrReplaceEditorHelperFunctions.cs +++ b/src/cycod/FunctionCallingTools/StrReplaceEditorHelperFunctions.cs @@ -305,6 +305,43 @@ public string CreateFile( return $"Created file {path} with {fileText.Length} characters."; } + [ReadOnly(false)] + [Description("Replaces the entire content of an existing file. Requires knowing the current line count as verification that you've read the file.")] + public string ReplaceFileContent( + [Description("Absolute or relative path to file.")] string path, + [Description("New content to replace the entire file.")] string newContent, + [Description("Current line count of the file (for verification).")] int oldContentLineCount) + { + if (!File.Exists(path)) + { + return $"File {path} does not exist. Use CreateFile to create a new file."; + } + + // Read current content and count lines + var currentContent = File.ReadAllText(path); + var currentLines = currentContent.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + var actualLineCount = currentLines.Length; + + // Verify line count matches + if (actualLineCount != oldContentLineCount) + { + return $"Line count mismatch: you specified {oldContentLineCount} but file has {actualLineCount} lines. Please read the file with ViewFile and verify the line count."; + } + + // Save current content for undo + if (!EditHistory.ContainsKey(path)) + { + EditHistory[path] = currentContent; + } + + // Replace entire content + File.WriteAllText(path, newContent); + var newLines = newContent.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + + return $"Replaced content in {path}: {actualLineCount} lines → {newLines.Length} lines ({newContent.Length} characters)."; + } + + // [ReadOnly(false)] // [Description("Replaces the lines in the file at `path` from `startLine` to `endLine` with the new string `newStr`. If `endLine` is -1, all remaining lines are replaced.")] // public string LinesReplace( @@ -378,6 +415,75 @@ public string ReplaceOneInFile( return $"File {path} updated: replaced 1 occurrence of specified text."; } + [ReadOnly(false)] + [Description("Replaces multiple text patterns in a single file atomically. All replacements must be unique occurrences.")] + public string ReplaceMultipleInFile( + [Description("Absolute or relative path to file.")] string path, + [Description("Array of old strings to be replaced. Each must match exactly one occurrence.")] string[] oldStrings, + [Description("Array of new strings to replace with. Must be same length as oldStrings.")] string[] newStrings) + { + if (!File.Exists(path)) + { + return $"File {path} does not exist."; + } + + if (oldStrings.Length != newStrings.Length) + { + return $"Error: oldStrings array length ({oldStrings.Length}) must match newStrings array length ({newStrings.Length})."; + } + + if (oldStrings.Length == 0) + { + return "Error: No replacements specified."; + } + + var originalText = FileHelpers.ReadAllText(path); + var currentText = originalText; + + // Validate all patterns exist and are unique before making any changes + for (int i = 0; i < oldStrings.Length; i++) + { + var oldStr = oldStrings[i]; + var newStr = newStrings[i]; + + var testReplacement = StringHelpers.ReplaceOnce(currentText, oldStr, newStr, out var countFound); + if (countFound != 1) + { + var message = countFound == 0 + ? $"Replacement {i + 1}: No occurrences of specified text found." + : $"Replacement {i + 1}: Multiple matches found for specified text; must be unique."; + return $"{message}\nNo changes made to {path}."; + } + } + + // All validations passed, now perform the replacements + for (int i = 0; i < oldStrings.Length; i++) + { + var oldStr = oldStrings[i]; + var newStr = newStrings[i]; + + var replacedText = StringHelpers.ReplaceOnce(currentText, oldStr, newStr, out var countFound); + if (replacedText != null && countFound == 1) + { + currentText = replacedText; + } + else + { + // This shouldn't happen since we validated, but handle gracefully + return $"Unexpected error during replacement {i + 1}. File may be in inconsistent state."; + } + } + + // Save original content for undo before writing + if (!EditHistory.ContainsKey(path)) + { + EditHistory[path] = originalText; + } + + File.WriteAllText(path, currentText); + + return $"File {path} updated: replaced {oldStrings.Length} occurrences."; + } [ReadOnly(false)] [Description("Inserts the specified string `newStr` into the file at `path` after the specified line number (`insertLine`). Use 0 to insert at the beginning of the file.")] public string Insert( diff --git a/tests/cycod-yaml/file-replacement-functions.yaml b/tests/cycod-yaml/file-replacement-functions.yaml new file mode 100644 index 00000000..463b21e9 --- /dev/null +++ b/tests/cycod-yaml/file-replacement-functions.yaml @@ -0,0 +1,150 @@ +tests: + # ReplaceFileContent tests + - name: ReplaceFileContent with correct line count succeeds + steps: + - name: Run test + bash: | + printf 'line 1\nline 2\nline 3' > test-replace-content.txt + cycod --input "use ReplaceFileContent to replace test-replace-content.txt with 'new content', it has 3 lines" --auto-approve '*' + expect-regex: | + assistant-function: ReplaceFileContent + path.*test-replace-content\.txt + newContent.*new content + oldContentLineCount.*3 + Replaced content.*3 lines.*1 lines + timeout: 30000 + - name: Cleanup + bash: rm -f test-replace-content.txt + optional: needsAI + + - name: ReplaceFileContent with wrong line count fails + steps: + - name: Run test + bash: | + printf 'line 1\nline 2\nline 3' > test-replace-fail.txt + cycod --input "use ReplaceFileContent to replace test-replace-fail.txt with 'new', say it has 5 lines (which is wrong)" --auto-approve '*' + expect-regex: | + assistant-function: ReplaceFileContent + oldContentLineCount.*5 + Line count mismatch.*you specified 5 but file has 3 lines + timeout: 30000 + - name: Cleanup + bash: rm -f test-replace-fail.txt + optional: needsAI + + - name: ReplaceFileContent on non-existent file fails + bash: | + cycod --input "use ReplaceFileContent to replace nonexistent-file.txt with 'content', say it has 1 line" --auto-approve '*' + expect-regex: | + assistant-function: ReplaceFileContent + File.*does not exist.*Use CreateFile + timeout: 30000 + optional: needsAI + + - name: ReplaceFileContent can be undone + steps: + - name: Run test + bash: | + echo "original content" > test-undo-replace.txt + cycod --input "First use ReplaceFileContent to replace test-undo-replace.txt with 'new content', it has 1 line. Then use UndoEdit to revert it." --auto-approve '*' + expect-regex: | + assistant-function: ReplaceFileContent + Replaced content + assistant-function: UndoEdit + Reverted last edit + timeout: 45000 + - name: Cleanup + bash: rm -f test-undo-replace.txt + optional: needsAI + + # ReplaceMultipleInFile tests + - name: ReplaceMultipleInFile replaces multiple patterns atomically + steps: + - name: Run test + bash: | + echo "foo bar baz" > test-multi-replace.txt + cycod --input "use ReplaceMultipleInFile on test-multi-replace.txt to replace 'foo' with 'FOO' and 'bar' with 'BAR'" --auto-approve '*' + expect-regex: | + assistant-function: ReplaceMultipleInFile + path.*test-multi-replace\.txt + oldStrings.*foo.*bar + newStrings.*FOO.*BAR + replaced 2 occurrences + timeout: 30000 + - name: Cleanup + bash: rm -f test-multi-replace.txt + optional: needsAI + + - name: ReplaceMultipleInFile fails if pattern not found + steps: + - name: Run test + bash: | + echo "foo bar" > test-multi-fail.txt + cycod --input "use ReplaceMultipleInFile on test-multi-fail.txt to replace 'foo' with 'FOO' and 'baz' with 'BAZ' (baz doesn't exist)" --auto-approve '*' + expect-regex: | + assistant-function: ReplaceMultipleInFile + No occurrences.*found|not found + No changes made + timeout: 30000 + - name: Cleanup + bash: rm -f test-multi-fail.txt + optional: needsAI + + - name: ReplaceMultipleInFile fails if array lengths don't match + steps: + - name: Run test + bash: | + echo "foo bar baz" > test-array-length.txt + cycod --input "use ReplaceMultipleInFile on test-array-length.txt with oldStrings=['foo','bar'] but newStrings=['FOO'] (mismatched lengths)" --auto-approve '*' + expect-regex: | + assistant-function: ReplaceMultipleInFile + array length.*must match + timeout: 30000 + - name: Cleanup + bash: rm -f test-array-length.txt + optional: needsAI + + - name: ReplaceMultipleInFile handles string arrays correctly + steps: + - name: Run test + bash: | + echo "alpha beta gamma delta" > test-string-array.txt + cycod --input "use ReplaceMultipleInFile on test-string-array.txt to replace ['alpha', 'beta', 'gamma'] with ['ALPHA', 'BETA', 'GAMMA']" --auto-approve '*' + expect-regex: | + assistant-function: ReplaceMultipleInFile + replaced 3 occurrences + timeout: 30000 + - name: Cleanup + bash: rm -f test-string-array.txt + optional: needsAI + + - name: ReplaceMultipleInFile can be undone + steps: + - name: Run test + bash: | + echo "foo bar baz" > test-multi-undo.txt + cycod --input "First use ReplaceMultipleInFile on test-multi-undo.txt to replace 'foo' with 'FOO' and 'bar' with 'BAR'. Then use UndoEdit to revert." --auto-approve '*' + expect-regex: | + assistant-function: ReplaceMultipleInFile + replaced 2 occurrences + assistant-function: UndoEdit + Reverted last edit + timeout: 45000 + - name: Cleanup + bash: rm -f test-multi-undo.txt + optional: needsAI + + - name: ReplaceMultipleInFile validates uniqueness of patterns + steps: + - name: Run test + bash: | + echo "test test test" > test-unique.txt + cycod --input "use ReplaceMultipleInFile on test-unique.txt to replace 'test' with 'TEST' (but 'test' appears multiple times, so should fail)" --auto-approve '*' + expect-regex: | + assistant-function: ReplaceMultipleInFile + Multiple matches found.*must be unique + No changes made + timeout: 30000 + - name: Cleanup + bash: rm -f test-unique.txt + optional: needsAI