Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/cycod/FunctionCalling/FunctionFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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!;
Expand All @@ -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);
}

Expand Down
106 changes: 106 additions & 0 deletions src/cycod/FunctionCallingTools/StrReplaceEditorHelperFunctions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
150 changes: 150 additions & 0 deletions tests/cycod-yaml/file-replacement-functions.yaml
Original file line number Diff line number Diff line change
@@ -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