diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/CopilotAgents/Tools/FileToolsTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/CopilotAgents/Tools/FileToolsTests.cs index 084593aca5b..1b7f2750e6d 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/CopilotAgents/Tools/FileToolsTests.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/CopilotAgents/Tools/FileToolsTests.cs @@ -403,4 +403,141 @@ public async Task ListFiles_EmptyDirectoryPath_DefaultsToRoot() Assert.That(entries, Is.Not.Null); Assert.That(entries, Does.Contain("rootfile.txt")); } + + // ── RenameFile tests ── + + [Test] + public async Task RenameFile_Success_RenamesFile() + { + var tool = FileTools.CreateRenameFileTool(baseDir); + var oldName = $"rename_src_{Guid.NewGuid()}.txt"; + var newName = $"rename_dst_{Guid.NewGuid()}.txt"; + File.WriteAllText(Path.Combine(baseDir, oldName), "content"); + + var result = await tool.InvokeAsync(new AIFunctionArguments + { + ["oldFilePath"] = oldName, + ["newFilePath"] = newName + }); + + Assert.That(result?.ToString(), Does.Contain("Successfully renamed")); + Assert.That(File.Exists(Path.Combine(baseDir, newName)), Is.True); + Assert.That(File.Exists(Path.Combine(baseDir, oldName)), Is.False); + Assert.That(File.ReadAllText(Path.Combine(baseDir, newName)), Is.EqualTo("content")); + } + + [Test] + public void RenameFile_SourceDoesNotExist_ThrowsArgumentException() + { + var tool = FileTools.CreateRenameFileTool(baseDir); + + var ex = Assert.ThrowsAsync(async () => + await tool.InvokeAsync(new AIFunctionArguments + { + ["oldFilePath"] = "nonexistent.txt", + ["newFilePath"] = "new.txt" + })); + Assert.That(ex!.Message, Does.Contain("does not exist")); + } + + [Test] + public void RenameFile_DestinationAlreadyExists_ThrowsArgumentException() + { + var tool = FileTools.CreateRenameFileTool(baseDir); + var oldName = $"rename_exists_src_{Guid.NewGuid()}.txt"; + var newName = $"rename_exists_dst_{Guid.NewGuid()}.txt"; + File.WriteAllText(Path.Combine(baseDir, oldName), "old"); + File.WriteAllText(Path.Combine(baseDir, newName), "new"); + + var ex = Assert.ThrowsAsync(async () => + await tool.InvokeAsync(new AIFunctionArguments + { + ["oldFilePath"] = oldName, + ["newFilePath"] = newName + })); + Assert.That(ex!.Message, Does.Contain("already exists")); + } + + [Test] + public void RenameFile_EmptyOldPath_ThrowsArgumentException() + { + var tool = FileTools.CreateRenameFileTool(baseDir); + + var ex = Assert.ThrowsAsync(async () => + await tool.InvokeAsync(new AIFunctionArguments + { + ["oldFilePath"] = "", + ["newFilePath"] = "new.txt" + })); + Assert.That(ex!.Message, Does.Contain("cannot be null or empty")); + } + + [Test] + public void RenameFile_EmptyNewPath_ThrowsArgumentException() + { + var tool = FileTools.CreateRenameFileTool(baseDir); + + var ex = Assert.ThrowsAsync(async () => + await tool.InvokeAsync(new AIFunctionArguments + { + ["oldFilePath"] = "rootfile.txt", + ["newFilePath"] = "" + })); + Assert.That(ex!.Message, Does.Contain("cannot be null or empty")); + } + + [Test] + public void RenameFile_PathOutsideBaseDir_ThrowsArgumentException() + { + var tool = FileTools.CreateRenameFileTool(baseDir); + + var ex = Assert.ThrowsAsync(async () => + await tool.InvokeAsync(new AIFunctionArguments + { + ["oldFilePath"] = "rootfile.txt", + ["newFilePath"] = "../outside.txt" + })); + Assert.That(ex!.Message, Does.Contain("outside the allowed base directory")); + } + + [Test] + public async Task RenameFile_CallbackInvoked_OnSuccess() + { + string? capturedOld = null; + string? capturedNew = null; + var tool = FileTools.CreateRenameFileTool(baseDir, + onFileRenamed: (o, n) => { capturedOld = o; capturedNew = n; }); + + var oldName = $"rename_cb_src_{Guid.NewGuid()}.txt"; + var newName = $"rename_cb_dst_{Guid.NewGuid()}.txt"; + File.WriteAllText(Path.Combine(baseDir, oldName), "content"); + + await tool.InvokeAsync(new AIFunctionArguments + { + ["oldFilePath"] = oldName, + ["newFilePath"] = newName + }); + + Assert.That(capturedOld, Is.EqualTo(oldName)); + Assert.That(capturedNew, Is.EqualTo(newName)); + } + + [Test] + public async Task RenameFile_CreatesDestinationDirectoryIfNeeded() + { + var tool = FileTools.CreateRenameFileTool(baseDir); + var oldName = $"rename_mkdir_src_{Guid.NewGuid()}.txt"; + var newDir = $"newdir_{Guid.NewGuid()}"; + var newName = Path.Combine(newDir, "moved.txt"); + File.WriteAllText(Path.Combine(baseDir, oldName), "content"); + + await tool.InvokeAsync(new AIFunctionArguments + { + ["oldFilePath"] = oldName, + ["newFilePath"] = newName + }); + + Assert.That(File.Exists(Path.Combine(baseDir, newName)), Is.True); + Assert.That(File.ReadAllText(Path.Combine(baseDir, newName)), Is.EqualTo("content")); + } } diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Helpers/DotNetPackageInfoHelperTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Helpers/DotNetPackageInfoHelperTests.cs index 24749dfd70d..c2afcd938b9 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Helpers/DotNetPackageInfoHelperTests.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Helpers/DotNetPackageInfoHelperTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.Sdk.Tools.Cli.CopilotAgents; using Azure.Sdk.Tools.Cli.Helpers; using Azure.Sdk.Tools.Cli.Models; using Azure.Sdk.Tools.Cli.Tests.TestHelpers; @@ -62,7 +63,7 @@ public async Task FindSamplesDirectory_WithSampleFiles_ReturnsSamplesDirectory() CreateTestFile(packagePath, "tests/unit/other.cs", "namespace Test; public class NotASample { }"); var packageInfoHelper = new PackageInfoHelper(NullLogger.Instance, gitHelper); - var helper = new DotnetLanguageService(processHelper, powershellHelper, gitHelper, new TestLogger(), commonValidationHelpers, packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()); + var helper = new DotnetLanguageService(processHelper, powershellHelper, Mock.Of(), gitHelper, new TestLogger(), commonValidationHelpers, packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()); // Act var packageInfo = await helper.GetPackageInfo(packagePath); @@ -85,7 +86,7 @@ public async Task FindSamplesDirectory_WithSnippetFiles_ReturnsSamplesDirectory( CreateTestFile(packagePath, "tests/unit/UnitTest.cs", "namespace Test; public class UnitTest { }"); var packageInfoHelper = new PackageInfoHelper(NullLogger.Instance, gitHelper); - var helper = new DotnetLanguageService(processHelper, powershellHelper, gitHelper, new TestLogger(), commonValidationHelpers, packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()); + var helper = new DotnetLanguageService(processHelper, powershellHelper, Mock.Of(), gitHelper, new TestLogger(), commonValidationHelpers, packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()); // Act var packageInfo = await helper.GetPackageInfo(packagePath); @@ -104,7 +105,7 @@ public async Task FindSamplesDirectory_WithNoSampleFiles_ReturnsDefaultPath() CreateTestFile(packagePath, "tests/unit/other.cs", "namespace Test; public class NotASample { }"); var packageInfoHelper = new PackageInfoHelper(NullLogger.Instance, gitHelper); - var helper = new DotnetLanguageService(processHelper, powershellHelper, gitHelper, new TestLogger(), commonValidationHelpers, packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()); + var helper = new DotnetLanguageService(processHelper, powershellHelper, Mock.Of(), gitHelper, new TestLogger(), commonValidationHelpers, packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()); // Act var packageInfo = await helper.GetPackageInfo(packagePath); @@ -120,7 +121,7 @@ public async Task FindSamplesDirectory_WithNoTestsDirectory_ReturnsDefaultPath() var (packagePath, gitHelper, processHelper, powershellHelper, commonValidationHelpers) = await CreateTestPackageAsync(); var packageInfoHelper = new PackageInfoHelper(NullLogger.Instance, gitHelper); - var helper = new DotnetLanguageService(processHelper, powershellHelper, gitHelper, new TestLogger(), commonValidationHelpers, packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()); + var helper = new DotnetLanguageService(processHelper, powershellHelper, Mock.Of(), gitHelper, new TestLogger(), commonValidationHelpers, packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()); // Act var packageInfo = await helper.GetPackageInfo(packagePath); @@ -140,7 +141,7 @@ public async Task FindSamplesDirectory_WithSnippetRegions_ReturnsCorrectDirector CreateTestFile(packagePath, "tests/examples/ExampleSNIPPET.cs", "#region Snippet:AnotherExample\nnamespace Test; public class ExampleSNIPPET { }\n#endregion"); var packageInfoHelper = new PackageInfoHelper(NullLogger.Instance, gitHelper); - var helper = new DotnetLanguageService(processHelper, powershellHelper, gitHelper, new TestLogger(), commonValidationHelpers, packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()); + var helper = new DotnetLanguageService(processHelper, powershellHelper, Mock.Of(), gitHelper, new TestLogger(), commonValidationHelpers, packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()); // Act var packageInfo = await helper.GetPackageInfo(packagePath); @@ -160,7 +161,7 @@ public async Task FindSamplesDirectory_WithMultipleDirectories_ReturnsFirstFound CreateTestFile(packagePath, "tests/snippets/BasicSnippet.cs", "#region Snippet:AnotherSnippet\nnamespace Test; public class BasicSnippet { }\n#endregion"); var packageInfoHelper = new PackageInfoHelper(NullLogger.Instance, gitHelper); - var helper = new DotnetLanguageService(processHelper, powershellHelper, gitHelper, new TestLogger(), commonValidationHelpers, packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()); + var helper = new DotnetLanguageService(processHelper, powershellHelper, Mock.Of(), gitHelper, new TestLogger(), commonValidationHelpers, packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()); // Act var packageInfo = await helper.GetPackageInfo(packagePath); @@ -197,7 +198,7 @@ public async Task GetPackageInfo_ParsesSdkTypeFromMSBuild(string sdkTypeValue, S var realProcessHelper = new ProcessHelper(new TestLogger(), Mock.Of()); var packageInfoHelper = new PackageInfoHelper(NullLogger.Instance, gitHelper); - var helper = new DotnetLanguageService(realProcessHelper, powershellHelper, gitHelper, new TestLogger(), commonValidationHelpers, packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()); + var helper = new DotnetLanguageService(realProcessHelper, powershellHelper, Mock.Of(), gitHelper, new TestLogger(), commonValidationHelpers, packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()); // Act var packageInfo = await helper.GetPackageInfo(packagePath); diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Prompts/Templates/DotnetErrorDrivenPatchTemplateTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Prompts/Templates/DotnetErrorDrivenPatchTemplateTests.cs new file mode 100644 index 00000000000..f0628a3e197 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Prompts/Templates/DotnetErrorDrivenPatchTemplateTests.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Sdk.Tools.Cli.Prompts.Templates; + +namespace Azure.Sdk.Tools.Cli.Tests.Prompts.Templates; + +[TestFixture] +public class DotnetErrorDrivenPatchTemplateTests +{ + private const string SampleBuildContext = """ + error CS0117: 'WidgetClient' does not contain a definition for 'GetWidgetAsync' + error CS0246: The type or namespace name 'WidgetOptions' could not be found + """; + + private const string SamplePackagePath = "/sdk/widget/Azure.Widget"; + private const string SampleCustomizationRoot = "/sdk/widget/Azure.Widget/src"; + + private static readonly List SampleCustomizationFiles = + [ + "src/WidgetClientExtensions.cs", + "src/Customized/WidgetOptionsHelper.cs" + ]; + + private static readonly List SamplePatchFilePaths = + [ + "WidgetClientExtensions.cs", + "Customized/WidgetOptionsHelper.cs" + ]; + + private DotnetErrorDrivenPatchTemplate CreateTemplate() => + new(SampleBuildContext, SamplePackagePath, SampleCustomizationRoot, + SampleCustomizationFiles, SamplePatchFilePaths); + + [Test] + public void TemplateId_IsDotnetErrorDrivenPatch() + { + var template = CreateTemplate(); + Assert.That(template.TemplateId, Is.EqualTo("dotnet-error-driven-patch")); + } + + [Test] + public void Version_Is1_0_0() + { + var template = CreateTemplate(); + Assert.That(template.Version, Is.EqualTo("1.0.0")); + } + + [Test] + public void Description_IsNotEmpty() + { + var template = CreateTemplate(); + Assert.That(template.Description, Is.Not.Null.And.Not.Empty); + } + + [Test] + public void BuildPrompt_ContainsBuildContext() + { + var prompt = CreateTemplate().BuildPrompt(); + Assert.That(prompt, Does.Contain("CS0117")); + Assert.That(prompt, Does.Contain("GetWidgetAsync")); + } + + [Test] + public void BuildPrompt_ContainsCustomizationFiles() + { + var prompt = CreateTemplate().BuildPrompt(); + Assert.Multiple(() => + { + Assert.That(prompt, Does.Contain("WidgetClientExtensions.cs")); + Assert.That(prompt, Does.Contain("WidgetOptionsHelper.cs")); + }); + } + + [Test] + public void BuildPrompt_ContainsPackagePath() + { + var prompt = CreateTemplate().BuildPrompt(); + Assert.That(prompt, Does.Contain(SamplePackagePath)); + } + + [Test] + public void BuildPrompt_ContainsCustomizationRoot() + { + var prompt = CreateTemplate().BuildPrompt(); + Assert.That(prompt, Does.Contain(SampleCustomizationRoot)); + } + + [Test] + public void BuildPrompt_ContainsDotnetSpecificGuidance() + { + var prompt = CreateTemplate().BuildPrompt(); + Assert.Multiple(() => + { + Assert.That(prompt, Does.Contain("partial class").IgnoreCase); + Assert.That(prompt, Does.Contain("Generated/")); + Assert.That(prompt, Does.Contain(".cs")); + Assert.That(prompt, Does.Contain(".NET")); + }); + } + + [Test] + public void BuildPrompt_ContainsToolDescriptions() + { + var prompt = CreateTemplate().BuildPrompt(); + Assert.Multiple(() => + { + Assert.That(prompt, Does.Contain("GrepSearch")); + Assert.That(prompt, Does.Contain("ReadFile")); + Assert.That(prompt, Does.Contain("CodePatchTool")); + }); + } + + [Test] + public void BuildPrompt_ContainsSafetyConstraints() + { + var prompt = CreateTemplate().BuildPrompt(); + Assert.Multiple(() => + { + Assert.That(prompt, Does.Contain("CUSTOMIZATION FILES ONLY")); + Assert.That(prompt, Does.Contain("SAFE PATCHES ONLY")); + Assert.That(prompt, Does.Contain("SURGICAL PATCHING")); + Assert.That(prompt, Does.Contain("NO DUPLICATE PATCHES")); + }); + } + + [Test] + public void BuildPrompt_ContainsWorkflowSteps() + { + var prompt = CreateTemplate().BuildPrompt(); + Assert.Multiple(() => + { + Assert.That(prompt, Does.Contain("Step 1")); + Assert.That(prompt, Does.Contain("Step 2")); + Assert.That(prompt, Does.Contain("Step 3")); + Assert.That(prompt, Does.Contain("Step 4")); + }); + } + + [Test] + public void BuildPrompt_ContainsReadFilePaths() + { + var prompt = CreateTemplate().BuildPrompt(); + foreach (var file in SampleCustomizationFiles) + { + Assert.That(prompt, Does.Contain(file), + $"Expected prompt to contain ReadFile path: {file}"); + } + } + + [Test] + public void BuildPrompt_ContainsPatchFilePaths() + { + var prompt = CreateTemplate().BuildPrompt(); + foreach (var file in SamplePatchFilePaths) + { + Assert.That(prompt, Does.Contain(file), + $"Expected prompt to contain CodePatchTool path: {file}"); + } + } +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/Languages/DotNetLanguageSpecificChecksTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/Languages/DotNetLanguageSpecificChecksTests.cs index ab20360a8fb..fbaa0487190 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/Languages/DotNetLanguageSpecificChecksTests.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/Languages/DotNetLanguageSpecificChecksTests.cs @@ -4,6 +4,7 @@ using Azure.Sdk.Tools.Cli.Tests.TestHelpers; using Microsoft.Extensions.Logging.Abstractions; using Moq; +using Azure.Sdk.Tools.Cli.CopilotAgents; namespace Azure.Sdk.Tools.Cli.Tests.Services.Languages; @@ -31,6 +32,7 @@ public void SetUp() _languageChecks = new DotnetLanguageService( _processHelperMock.Object, _powerShellHelperMock.Object, + Mock.Of(), _gitHelperMock.Object, NullLogger.Instance, _commonValidationHelperMock.Object, diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/Languages/DotnetLanguageServicePatchTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/Languages/DotnetLanguageServicePatchTests.cs new file mode 100644 index 00000000000..4831b2c4d4e --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/Languages/DotnetLanguageServicePatchTests.cs @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Sdk.Tools.Cli.CopilotAgents; +using Azure.Sdk.Tools.Cli.Helpers; +using Azure.Sdk.Tools.Cli.Services; +using Azure.Sdk.Tools.Cli.Services.Languages; +using Azure.Sdk.Tools.Cli.Tests.TestHelpers; +using Moq; + +namespace Azure.Sdk.Tools.Cli.Tests.Services.Languages; + +[TestFixture] +public class DotnetLanguageServicePatchTests +{ + private Mock _processHelper = null!; + private Mock _powershellHelper = null!; + private Mock _copilotAgentRunner = null!; + private Mock _gitHelper = null!; + private DotnetLanguageService _service = null!; + private TempDirectory _tempDir = null!; + + [SetUp] + public void SetUp() + { + _processHelper = new Mock(); + _powershellHelper = new Mock(); + _copilotAgentRunner = new Mock(); + _gitHelper = new Mock(); + _tempDir = TempDirectory.Create("dotnet-patch-tests"); + + _service = new DotnetLanguageService( + _processHelper.Object, + _powershellHelper.Object, + _copilotAgentRunner.Object, + _gitHelper.Object, + new TestLogger(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); + } + + [TearDown] + public void TearDown() => _tempDir.Dispose(); + + [Test] + public async Task ApplyPatchesAsync_CustomizationRootDoesNotExist_ReturnsEmptyList() + { + var nonExistentRoot = Path.Combine(_tempDir.DirectoryPath, "nonexistent"); + + var result = await _service.ApplyPatchesAsync( + nonExistentRoot, + _tempDir.DirectoryPath, + "error CS0117: some error", + CancellationToken.None); + + Assert.That(result, Is.Empty); + _copilotAgentRunner.Verify( + r => r.RunAsync(It.IsAny>(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task ApplyPatchesAsync_NoCsFiles_ReturnsEmptyList() + { + var customizationRoot = Path.Combine(_tempDir.DirectoryPath, "src"); + Directory.CreateDirectory(customizationRoot); + + var result = await _service.ApplyPatchesAsync( + customizationRoot, + _tempDir.DirectoryPath, + "error CS0117: some error", + CancellationToken.None); + + Assert.That(result, Is.Empty); + _copilotAgentRunner.Verify( + r => r.RunAsync(It.IsAny>(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task ApplyPatchesAsync_WithCsFiles_CallsAgentRunner() + { + var customizationRoot = Path.Combine(_tempDir.DirectoryPath, "src"); + Directory.CreateDirectory(customizationRoot); + await File.WriteAllTextAsync( + Path.Combine(customizationRoot, "WidgetClientExtensions.cs"), + "public partial class WidgetClient { }"); + + _copilotAgentRunner + .Setup(r => r.RunAsync(It.IsAny>(), It.IsAny())) + .Returns(Task.FromResult("done")); + + var result = await _service.ApplyPatchesAsync( + customizationRoot, + _tempDir.DirectoryPath, + "error CS0117: 'WidgetClient' does not contain a definition for 'GetWidgetAsync'", + CancellationToken.None); + + _copilotAgentRunner.Verify( + r => r.RunAsync( + It.Is>(a => + a.MaxIterations == 10 && + a.Instructions.Contains("WidgetClient") && + a.Instructions.Contains("CS0117")), + It.IsAny()), + Times.Once); + } + + [Test] + public async Task ApplyPatchesAsync_WithCsFiles_IncludesRenameFileTool() + { + var customizationRoot = Path.Combine(_tempDir.DirectoryPath, "src"); + Directory.CreateDirectory(customizationRoot); + await File.WriteAllTextAsync( + Path.Combine(customizationRoot, "WidgetClient.cs"), + "public partial class WidgetClient { }"); + + _copilotAgentRunner + .Setup(r => r.RunAsync(It.IsAny>(), It.IsAny())) + .Returns(Task.FromResult("done")); + + await _service.ApplyPatchesAsync( + customizationRoot, + _tempDir.DirectoryPath, + "error CS0246: type or namespace name 'WidgetClient' could not be found", + CancellationToken.None); + + _copilotAgentRunner.Verify( + r => r.RunAsync( + It.Is>(a => + a.Tools.Any(t => t.Name == "RenameFile")), + It.IsAny()), + Times.Once); + } + + [Test] + public async Task ApplyPatchesAsync_AgentThrows_ReturnsEmptyList() + { + var customizationRoot = Path.Combine(_tempDir.DirectoryPath, "src"); + Directory.CreateDirectory(customizationRoot); + await File.WriteAllTextAsync( + Path.Combine(customizationRoot, "Test.cs"), + "public partial class Test { }"); + + _copilotAgentRunner + .Setup(r => r.RunAsync(It.IsAny>(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Agent failed")); + + var result = await _service.ApplyPatchesAsync( + customizationRoot, + _tempDir.DirectoryPath, + "error CS0117: some error", + CancellationToken.None); + + Assert.That(result, Is.Empty); + } + + [Test] + public async Task ApplyPatchesAsync_ExcludesGeneratedFiles() + { + var customizationRoot = Path.Combine(_tempDir.DirectoryPath, "src"); + var generatedDir = Path.Combine(customizationRoot, "Generated"); + Directory.CreateDirectory(customizationRoot); + Directory.CreateDirectory(generatedDir); + + // Create a customization file (should be included) + await File.WriteAllTextAsync( + Path.Combine(customizationRoot, "WidgetClient.cs"), + "public partial class WidgetClient { }"); + + // Create a generated file (should be excluded) + await File.WriteAllTextAsync( + Path.Combine(generatedDir, "WidgetClient.cs"), + "public partial class WidgetClient { // generated }"); + + _copilotAgentRunner + .Setup(r => r.RunAsync(It.IsAny>(), It.IsAny())) + .Returns(Task.FromResult("done")); + + await _service.ApplyPatchesAsync( + customizationRoot, + _tempDir.DirectoryPath, + "error CS0117: some error", + CancellationToken.None); + + // Verify the agent was called with instructions that contain the customization file + // but not the generated file path in the file lists + _copilotAgentRunner.Verify( + r => r.RunAsync( + It.Is>(a => + a.Instructions.Contains("WidgetClient.cs") && + !a.Instructions.Contains("Generated" + Path.DirectorySeparatorChar + "WidgetClient.cs")), + It.IsAny()), + Times.Once); + } + + [Test] + public void ApplyPatchesAsync_CancellationRequested_Throws() + { + var customizationRoot = Path.Combine(_tempDir.DirectoryPath, "src"); + Directory.CreateDirectory(customizationRoot); + File.WriteAllText( + Path.Combine(customizationRoot, "Test.cs"), + "public partial class Test { }"); + + _copilotAgentRunner + .Setup(r => r.RunAsync(It.IsAny>(), It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + Assert.ThrowsAsync(() => + _service.ApplyPatchesAsync( + customizationRoot, + _tempDir.DirectoryPath, + "error CS0117: some error", + CancellationToken.None)); + } +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/Languages/LanguageServicePackageInfoTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/Languages/LanguageServicePackageInfoTests.cs index a2f2664e1a2..d7c5c41705a 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/Languages/LanguageServicePackageInfoTests.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/Languages/LanguageServicePackageInfoTests.cs @@ -31,7 +31,7 @@ public void Setup() var pythonHelper = new PythonHelper(new TestLogger(), Mock.Of()); var packageInfoHelper = new PackageInfoHelper(new TestLogger(), gitHelper.Object); languageServices = [ - new DotnetLanguageService(processHelper, Mock.Of(), gitHelper.Object, new TestLogger(), Mock.Of(), packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()), + new DotnetLanguageService(processHelper, Mock.Of(), Mock.Of(), gitHelper.Object, new TestLogger(), Mock.Of(), packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()), new JavaLanguageService(new Mock().Object, gitHelper.Object, new Mock().Object, pythonHelper, new Mock().Object, new TestLogger(), Mock.Of(), packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()), new PythonLanguageService(new Mock().Object, pythonHelper, new Mock().Object, Mock.Of(), gitHelper.Object, new TestLogger(), Mock.Of(), packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()), new JavaScriptLanguageService(new Mock().Object, new Mock().Object, gitHelper.Object, new TestLogger(), Mock.Of(), packageInfoHelper, Mock.Of(), Mock.Of(), Mock.Of()), diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Generators/SampleGeneratorToolTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Generators/SampleGeneratorToolTests.cs index f179a27a738..083829bfd54 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Generators/SampleGeneratorToolTests.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Generators/SampleGeneratorToolTests.cs @@ -370,7 +370,7 @@ public void Setup() new JavaLanguageService(_mockProcessHelper.Object, realGitHelper, new Mock().Object, _mockPythonHelper.Object, copilotAgentRunnerMock.Object, languageLogger, _commonValidationHelpers.Object, packageInfoHelper, fileHelper, Mock.Of(), Mock.Of()), new JavaScriptLanguageService(_mockProcessHelper.Object, _mockNpxHelper.Object, realGitHelper, languageLogger, _commonValidationHelpers.Object, packageInfoHelper, fileHelper, Mock.Of(), Mock.Of()), _mockGoLanguageService.Object, - new DotnetLanguageService(_mockProcessHelper.Object, _mockPowerShellHelper.Object, realGitHelper, languageLogger, _commonValidationHelpers.Object, packageInfoHelper, fileHelper, Mock.Of(), Mock.Of()) + new DotnetLanguageService(_mockProcessHelper.Object, _mockPowerShellHelper.Object, Mock.Of(), realGitHelper, languageLogger, _commonValidationHelpers.Object, packageInfoHelper, fileHelper, Mock.Of(), Mock.Of()) ]; _mockGoLanguageService.Setup(ls => ls.Language).Returns(SdkLanguage.Go); diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Package/PackToolTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Package/PackToolTests.cs index 54632e70c7a..832c1740729 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Package/PackToolTests.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Package/PackToolTests.cs @@ -79,7 +79,7 @@ public void Setup() new JavaLanguageService(_mockProcessHelper.Object, _mockGitHelper.Object, _mockMavenHelper.Object, Mock.Of(), mockCopilotAgentRunner.Object, languageLogger, _commonValidationHelpers.Object, _mockPackageInfoHelper.Object, Mock.Of(), _mockSpecGenSdkConfigHelper.Object, Mock.Of()), new JavaScriptLanguageService(_mockProcessHelper.Object, _mockNpxHelper.Object, _mockGitHelper.Object, languageLogger, _commonValidationHelpers.Object, _mockPackageInfoHelper.Object, Mock.Of(), _mockSpecGenSdkConfigHelper.Object, Mock.Of()), new GoLanguageService(_mockProcessHelper.Object, _mockPowerShellHelper.Object, _mockGitHelper.Object, languageLogger, _commonValidationHelpers.Object, _mockPackageInfoHelper.Object, Mock.Of(), _mockSpecGenSdkConfigHelper.Object, Mock.Of()), - new DotnetLanguageService(_mockProcessHelper.Object, _mockPowerShellHelper.Object, _mockGitHelper.Object, languageLogger, _commonValidationHelpers.Object, _mockPackageInfoHelper.Object, Mock.Of(), _mockSpecGenSdkConfigHelper.Object, Mock.Of()) + new DotnetLanguageService(_mockProcessHelper.Object, _mockPowerShellHelper.Object, Mock.Of(), _mockGitHelper.Object, languageLogger, _commonValidationHelpers.Object, _mockPackageInfoHelper.Object, Mock.Of(), _mockSpecGenSdkConfigHelper.Object, Mock.Of()) ]; _tool = new PackTool( diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Package/SdkBuildToolTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Package/SdkBuildToolTests.cs index d58dcde4f61..30573141ca5 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Package/SdkBuildToolTests.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Package/SdkBuildToolTests.cs @@ -66,7 +66,7 @@ public void Setup() new JavaLanguageService(_mockProcessHelper.Object, _mockGitHelper.Object, new Mock().Object, _mockPythonHelper.Object, copilotAgentRunnerMock.Object, languageLogger, _commonValidationHelpers.Object, packageInfoHelper, Mock.Of(), _mockSpecGenSdkConfigHelper.Object, Mock.Of()), new JavaScriptLanguageService(_mockProcessHelper.Object, _mockNpxHelper.Object, _mockGitHelper.Object, languageLogger, _commonValidationHelpers.Object, packageInfoHelper, Mock.Of(), _mockSpecGenSdkConfigHelper.Object, Mock.Of()), new GoLanguageService(_mockProcessHelper.Object, _mockPowerShellHelper.Object, _mockGitHelper.Object, languageLogger, _commonValidationHelpers.Object, packageInfoHelper, Mock.Of(), _mockSpecGenSdkConfigHelper.Object, Mock.Of()), - new DotnetLanguageService(_mockProcessHelper.Object, _mockPowerShellHelper.Object, _mockGitHelper.Object, languageLogger, _commonValidationHelpers.Object, packageInfoHelper, Mock.Of(), _mockSpecGenSdkConfigHelper.Object, Mock.Of()) + new DotnetLanguageService(_mockProcessHelper.Object, _mockPowerShellHelper.Object, Mock.Of(), _mockGitHelper.Object, languageLogger, _commonValidationHelpers.Object, packageInfoHelper, Mock.Of(), _mockSpecGenSdkConfigHelper.Object, Mock.Of()) ]; // Create the tool instance diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/CopilotAgents/Tools/FileTools.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/CopilotAgents/Tools/FileTools.cs index 4dfa1aeb465..841d78055d6 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/CopilotAgents/Tools/FileTools.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/CopilotAgents/Tools/FileTools.cs @@ -356,5 +356,60 @@ private static IEnumerable EnumerateFileSystemEntries(string rootPath, s } } + /// + /// Creates a RenameFile tool that renames (moves) a file within a base directory. + /// + /// The base directory for relative path resolution. + /// Optional custom description for the tool. + /// Optional callback invoked after a successful rename. + /// An AIFunction that renames files. + public static AIFunction CreateRenameFileTool( + string baseDir, + string description = "Rename a file within the project directory", + Action? onFileRenamed = null) + { + return AIFunctionFactory.Create( + ([Description("Current relative path of the file to rename")] string oldFilePath, + [Description("New relative path for the file (typically just a new filename in the same directory)")] string newFilePath) => + { + if (string.IsNullOrEmpty(oldFilePath)) + { + throw new ArgumentException("Old file path cannot be null or empty.", nameof(oldFilePath)); + } + if (string.IsNullOrEmpty(newFilePath)) + { + throw new ArgumentException("New file path cannot be null or empty.", nameof(newFilePath)); + } + if (!ToolHelpers.TryGetSafeFullPath(baseDir, oldFilePath, out var safeOldPath)) + { + throw new ArgumentException("The old path is invalid or outside the allowed base directory."); + } + if (!ToolHelpers.TryGetSafeFullPath(baseDir, newFilePath, out var safeNewPath)) + { + throw new ArgumentException("The new path is invalid or outside the allowed base directory."); + } + if (!File.Exists(safeOldPath)) + { + throw new ArgumentException($"Source file does not exist: {oldFilePath}"); + } + if (File.Exists(safeNewPath)) + { + throw new ArgumentException($"Destination file already exists: {newFilePath}"); + } + + var newDir = Path.GetDirectoryName(safeNewPath); + if (!string.IsNullOrEmpty(newDir) && !Directory.Exists(newDir)) + { + Directory.CreateDirectory(newDir); + } + + File.Move(safeOldPath, safeNewPath); + onFileRenamed?.Invoke(oldFilePath, newFilePath); + return $"Successfully renamed {oldFilePath} to {newFilePath}"; + }, + "RenameFile", + description); + } + private readonly record struct GrepMatch(int FileIndex, int LineNumber, string FilePath, string Content); } diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Program.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Program.cs index 3491fc081bf..e260627fe6e 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Program.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Program.cs @@ -107,4 +107,4 @@ public static WebApplicationBuilder CreateAppBuilder(string[] args, string outpu return builder; } -} \ No newline at end of file +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Prompts/Templates/DotnetErrorDrivenPatchTemplate.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Prompts/Templates/DotnetErrorDrivenPatchTemplate.cs new file mode 100644 index 00000000000..159389b6ce3 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Prompts/Templates/DotnetErrorDrivenPatchTemplate.cs @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Sdk.Tools.Cli.Prompts.Templates; + +/// +/// Error-driven template for .NET customization patching. +/// Grounded in Azure SDK for .NET partial-class customization patterns, it applies safe structural patches based on build errors. +/// +public class DotnetErrorDrivenPatchTemplate( + string buildContext, + string packagePath, + string customizationRoot, + List customizationFiles, + List patchFilePaths) : BasePromptTemplate +{ + public override string TemplateId => "dotnet-error-driven-patch"; + public override string Version => "1.0.0"; + public override string Description => + "Analyze C# build errors and apply safe structural patches to customization files"; + + public override string BuildPrompt() => + BuildStructuredPrompt( + BuildTaskInstructions(), + BuildTaskConstraints(), + BuildOutputRequirements()); + + private string BuildTaskInstructions() + { + var readFileList = string.Join("\n", customizationFiles.Select(f => $" - ReadFile path: `{f}`")); + var patchFileList = string.Join("\n", patchFilePaths.Select(f => $" - CodePatchTool path: `{f}`")); + + return $$""" + ## CONTEXT + You are a worker for the .NET Azure SDK customization repair pipeline. + Your job is to apply SAFE structural patches to fix build errors in customization files. + + ## BUILD CONTEXT + ``` + {{buildContext}} + ``` + + ## INPUT STRUCTURE + - Package path: {{packagePath}} + - Customization root: {{customizationRoot}} + - Customization files: + {{readFileList}} + {{patchFileList}} + + ## .NET CUSTOMIZATION PATTERN + In azure-sdk-for-net, generated code lives in a `Generated/` folder under `src/`. + Customizations are **partial classes** defined in `.cs` files OUTSIDE the `Generated/` folder. + These partial classes extend the generated types to add or override behavior. + + Common customization patterns: + - Partial class extending a generated model or client class + - Adding convenience methods or properties + - Overriding serialization behavior + - Adding custom constructors or factory methods + + When generated code changes (e.g. a property is renamed, a method signature changes), + the customization partial classes may reference stale names and fail to compile. + + ## TOOLS & FILE PATHS + The following tools are available. They use DIFFERENT base directories: + + **GrepSearch** — resolves paths relative to the package path: `{{packagePath}}` + - Search for text patterns in files without reading entire files. + - Returns matching lines with file paths and line numbers. + - Use `path: "."` to search across all files, or a specific relative path. + + **ReadFile** — resolves paths relative to the package path: `{{packagePath}}` + - Generated code: `src/Generated/.cs` + - Customization code: `src/.cs` or `src/Customized/.cs` + - Supports `startLine`/`endLine` parameters to read specific sections. + + **CodePatchTool** — resolves paths relative to the customization root: `{{customizationRoot}}` + - Use ONLY the filename or path relative to the customization root. + - Example: if customization root is `.../src`, use just `WidgetClientExtensions.cs` + or `Customized/WidgetClient.cs`. + + **RenameFile** — resolves paths relative to the customization root: `{{customizationRoot}}` + - Renames a customization file. Use when a class has been renamed and the file name + should match the new class name. + - Parameters: `oldFilePath` (current relative path), `newFilePath` (new relative path). + - Example: `RenameFile oldFilePath: "OldClient.cs" newFilePath: "NewClient.cs"` + + ## WORKFLOW + + ### Step 1 — Parse errors + - Read the Original Request and Classifier Analysis in the BUILD CONTEXT above. + These tell you WHAT was changed (e.g., a property was renamed) and WHY the build broke. + - Extract each compiler error: failing symbol, file, line, error code (e.g. CS0117, CS0246). + + ### Step 2 — Find and read relevant code + - **Use GrepSearch first** to find the failing symbol (e.g., the old property name) in + the customization files. This tells you exactly which lines reference it. + - Then use **ReadFile with startLine/endLine** to read ~20 lines around each match + to understand the surrounding context. + - Customization code uses partial classes that reference members from generated types. + When a build error appears in a customization file, the root cause is usually a + reference to a renamed or removed member from the generated type. + - Also read the generated file(s) referenced in errors (in the `Generated/` folder) + to confirm current property names, method signatures, and type definitions. + + ### Step 3 — Apply safe patches + Apply patches ONLY when you can determine the CORRECT value with certainty: + - Property was renamed → update all references in partial class + - Method signature changed → update method calls with new parameters + - Type was renamed → update type references and using directives + - Namespace changed → update using statements + - Method was removed → update to use the replacement method if one exists + + ### Step 3b — Rename files when classes are renamed + When a class or type is renamed and you patch the partial class declaration to use + the new class name, you MUST ALSO rename the customization file to match the new class name. + - In .NET, the convention is that the file name matches the class name (e.g., `WidgetClient.cs` + contains `partial class WidgetClient`). + - Use the **RenameFile** tool to rename the file AFTER applying the code patch. + - Example: if you rename `partial class OldClient` to `partial class NewClient`, + also call `RenameFile oldFilePath: "OldClient.cs" newFilePath: "NewClient.cs"`. + - **CRITICAL**: When renaming a class, ONLY patch the class declaration line itself + (e.g., `partial class OldName` → `partial class NewName`). Do NOT modify, delete, or + rewrite any method bodies, properties, or other members inside the class. Errors about + missing members (CS0103) are cascading errors that resolve once the class name matches + the generated partial class again. + + ### Step 4 — Return summary + If you applied patches, return a brief summary of what was fixed. + If no patches could be applied, return empty string. + + """; + } + + private string BuildTaskConstraints() + { + return """ + + ### 1. CUSTOMIZATION FILES ONLY + You may patch ONLY the customization files provided. Never patch generated code in the `Generated/` folder. + + ### 2. SAFE PATCHES ONLY + Apply patches only when the fix is obvious and correct. + **DO NOT**: + - Pass `null` for a new parameter whose correct value is unknown + - Remove or comment out method calls to suppress errors + - Add placeholder/dummy values + - Guess at correct values + - Remove `partial` keyword or change class hierarchy + - Delete or rewrite existing method bodies + + ### 3. SURGICAL PATCHING + The CodePatchTool uses **surgical text replacement**: + - `StartLine`/`EndLine`: The line range containing the text to modify + - `OldText`: The EXACT text fragment to find (can span multiple lines) + - `NewText`: The replacement text + - `PatchDescription`: A brief human-readable summary of the change + (e.g., "Renamed MaxSpeakers to MaxSpeakerCount in partial class extension") + + **Example**: To rename a property reference in a partial class: + ``` + StartLine: 15 + EndLine: 15 + OldText: "response.Value.OldPropertyName" + NewText: "response.Value.NewPropertyName" + PatchDescription: "Updated property reference from OldPropertyName to NewPropertyName" + ``` + + This surgically replaces ONLY that text, preserving all surrounding syntax. + + **IMPORTANT**: Never replace large blocks of code (e.g., entire method bodies or whole file content). + Each patch should target the smallest possible text fragment. If you need to rename a class, + patch ONLY the class declaration line — do NOT touch method bodies inside the class. + + ### 4. GREP FIRST, READ RANGES, THEN PATCH + - Use GrepSearch to locate the failing symbol in customization files. + - Use ReadFile with startLine/endLine to read context around the matches. + - Use the line numbers from ReadFile output for StartLine/EndLine in patches. + - For OldText, copy the EXACT text from the file (you can span multiple lines). + + ### 5. NO DUPLICATE PATCHES + - Each OldText can only be replaced once per file. + - If a patch is rejected, STOP and return. + + ### 6. IGNORE CASCADING ERRORS FROM CLASS RENAMES + When a partial class name no longer matches its generated counterpart, the compiler will report + multiple cascading errors for members that actually exist on the generated type (e.g., CS0103 + for `ClientDiagnostics`, method names, properties). These errors are **NOT real** — they will + resolve automatically once the partial class name is corrected to match the generated class. + **DO NOT** try to fix these cascading errors individually (e.g., by removing method calls, + rewriting method bodies, or adding new members). Just rename the class declaration and the file. + + ### 7. IGNORE ANALYZER RULES + Ignore analyzer warnings and errors with codes like `AZC0007`, `SA1517`, or any `AZC*`/`SA*` + prefixed codes. These are style/design analyzers, not compilation errors. Do NOT add constructors, + reformat code, or make any changes to satisfy analyzer rules. + + """; + } + + private string BuildOutputRequirements() + { + return """ + Return a brief summary of what you did: + - If patches were applied: describe each fix + - If no patches could be applied: return empty string "" + + Keep it concise. + """; + } + +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/Languages/DotnetLanguageService.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/Languages/DotnetLanguageService.cs index d9dde50ed27..ecf961bd8d6 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/Languages/DotnetLanguageService.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/Languages/DotnetLanguageService.cs @@ -1,10 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Concurrent; using System.Diagnostics; using System.Text.Json; +using Azure.Sdk.Tools.Cli.CopilotAgents; +using Azure.Sdk.Tools.Cli.CopilotAgents.Tools; using Azure.Sdk.Tools.Cli.Helpers; using Azure.Sdk.Tools.Cli.Models; using Azure.Sdk.Tools.Cli.Models.Responses.Package; +using Azure.Sdk.Tools.Cli.Prompts.Templates; namespace Azure.Sdk.Tools.Cli.Services.Languages; @@ -20,10 +24,12 @@ public sealed partial class DotnetLanguageService: LanguageService private static readonly TimeSpan AotCompatTimeout = TimeSpan.FromMinutes(5); private readonly IPowershellHelper powershellHelper; + private readonly ICopilotAgentRunner copilotAgentRunner; public DotnetLanguageService( IProcessHelper processHelper, IPowershellHelper powershellHelper, + ICopilotAgentRunner copilotAgentRunner, IGitHelper gitHelper, ILogger logger, ICommonValidationHelpers commonValidationHelpers, @@ -34,6 +40,7 @@ public DotnetLanguageService( : base(processHelper, gitHelper, logger, commonValidationHelpers, packageInfoHelper, fileHelper, specGenSdkConfigHelper, changelogHelper) { this.powershellHelper = powershellHelper; + this.copilotAgentRunner = copilotAgentRunner; } public override SdkLanguage Language { get; } = SdkLanguage.DotNet; @@ -371,4 +378,105 @@ public override async Task RunAllTests(string packagePath, Canc return null; } } + + /// + /// Applies patches to customization files based on build errors. + /// This is a mechanical worker - the Classifier does the thinking and routing. + /// + public override async Task> ApplyPatchesAsync( + string customizationRoot, + string packagePath, + string buildContext, + CancellationToken ct) + { + try + { + if (!Directory.Exists(customizationRoot)) + { + logger.LogDebug("Customization root does not exist: {Root}", customizationRoot); + return []; + } + + // Get the list of customization files, excluding generated code + var generatedDirMarker = Path.DirectorySeparatorChar + GeneratedFolderName + Path.DirectorySeparatorChar; + var csFiles = Directory.GetFiles(customizationRoot, "*.cs", SearchOption.AllDirectories) + .Where(f => !f.Contains(generatedDirMarker, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (csFiles.Length == 0) + { + logger.LogDebug("No customization .cs files found in: {Root}", customizationRoot); + return []; + } + + // Collect patches directly from the tool as they succeed + var patchLog = new ConcurrentBag(); + + // Build both relative-path lists in a single pass: + // - customizationFiles: relative to packagePath (for the ReadFile tool) + // - patchFilePaths: relative to customizationRoot (for the CodePatch tool) + var customizationFiles = new List(csFiles.Length); + var patchFilePaths = new List(csFiles.Length); + foreach (var f in csFiles) + { + customizationFiles.Add(Path.GetRelativePath(packagePath, f)); + patchFilePaths.Add(Path.GetRelativePath(customizationRoot, f)); + } + + // Build error-driven prompt for patch agent + var prompt = new DotnetErrorDrivenPatchTemplate( + buildContext, + packagePath, + customizationRoot, + customizationFiles, + patchFilePaths).BuildPrompt(); + + // Single-pass agent: applies all patches it can in one run + var agentDefinition = new CopilotAgent + { + Instructions = prompt, + MaxIterations = 10, + Tools = + [ + FileTools.CreateReadFileTool(packagePath, includeLineNumbers: true, + description: "Read files from the package directory (generated code, customization files, etc.). Use startLine/endLine to read specific sections of large files."), + FileTools.CreateGrepSearchTool(packagePath, + description: "Search for text or regex patterns in files. Use this to find specific symbols or references without reading entire files."), + CodePatchTools.CreateCodePatchTool(customizationRoot, + description: "Apply code patches to customization files only (never generated code)", + onPatchApplied: patchLog.Add), + FileTools.CreateRenameFileTool(customizationRoot, + description: "Rename a customization file (e.g., when a class is renamed, the file should be renamed to match). Paths are relative to the customization root.", + onFileRenamed: (oldPath, newPath) => patchLog.Add(new AppliedPatch(newPath, $"Renamed file from {oldPath} to {newPath}", 1))) + ] + }; + + // Run the agent to apply patches + try + { + await copilotAgentRunner.RunAsync(agentDefinition, ct); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception agentEx) + { + // Agent exhausted its iteration budget without completing. + logger.LogDebug(agentEx, "CopilotAgent terminated early"); + } + + // The patchLog was populated directly by the tool on each successful patch + var appliedPatches = patchLog.ToList(); + + logger.LogInformation("Patch application completed, patches applied: {PatchCount}", appliedPatches.Count); + return appliedPatches; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + logger.LogError(ex, "Failed to apply patches"); + return []; + } + } }