From e6918c3334df2cd1a42aa9f1ac565d52dbd8a2c5 Mon Sep 17 00:00:00 2001 From: Maddy Redding Heaps Date: Wed, 11 Mar 2026 14:30:16 -0700 Subject: [PATCH 1/7] initial customization work --- .../DotnetPatchCustomizationScenario.cs | 79 +++++++ .../Helpers/DotNetPackageInfoHelperTests.cs | 15 +- .../DotnetErrorDrivenPatchTemplateTests.cs | 161 +++++++++++++++ .../DotNetLanguageSpecificChecksTests.cs | 2 + .../DotnetLanguageServicePatchTests.cs | 194 ++++++++++++++++++ .../LanguageServicePackageInfoTests.cs | 2 +- .../Generators/SampleGeneratorToolTests.cs | 2 +- .../Tools/Package/PackToolTests.cs | 2 +- .../Tools/Package/SdkBuildToolTests.cs | 2 +- .../DotnetErrorDrivenPatchTemplate.cs | 176 ++++++++++++++++ .../Languages/DotnetLanguageService.cs | 104 ++++++++++ 11 files changed, 728 insertions(+), 11 deletions(-) create mode 100644 tools/azsdk-cli/Azure.Sdk.Tools.Cli.Benchmarks/Scenarios/Typespec/DotnetPatchCustomizationScenario.cs create mode 100644 tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Prompts/Templates/DotnetErrorDrivenPatchTemplateTests.cs create mode 100644 tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/Languages/DotnetLanguageServicePatchTests.cs create mode 100644 tools/azsdk-cli/Azure.Sdk.Tools.Cli/Prompts/Templates/DotnetErrorDrivenPatchTemplate.cs diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Benchmarks/Scenarios/Typespec/DotnetPatchCustomizationScenario.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Benchmarks/Scenarios/Typespec/DotnetPatchCustomizationScenario.cs new file mode 100644 index 00000000000..df34db3e5e7 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Benchmarks/Scenarios/Typespec/DotnetPatchCustomizationScenario.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Azure.Sdk.Tools.Cli.Benchmarks.Infrastructure; +using Azure.Sdk.Tools.Cli.Benchmarks.Models; +using Azure.Sdk.Tools.Cli.Benchmarks.Validation; +using Azure.Sdk.Tools.Cli.Benchmarks.Validation.Validators; + +namespace Azure.Sdk.Tools.Cli.Benchmarks.Scenarios; + +/// +/// Scenario that tests the .NET error-driven patch template by introducing a breaking +/// property rename in a generated type and verifying that the agent patches the +/// customization file to use the new name. +/// +/// Target: Azure.AI.DocumentIntelligence in azure-sdk-for-net. +/// The setup renames a property in the generated code to simulate a regeneration +/// breaking change, then the agent should patch the customization partial class. +/// +public class DotnetPatchCustomizationScenario : BenchmarkScenario +{ + private const string PackageDir = "sdk/documentintelligence/Azure.AI.DocumentIntelligence"; + private const string CustomizationFile = $"{PackageDir}/src/DocumentIntelligenceClient.cs"; + private const string GeneratedFile = $"{PackageDir}/src/Generated/DocumentIntelligenceClient.cs"; + + /// + public override string Name => "dotnet-patch-customization"; + + /// + public override string Description => + "Test .NET error-driven patching: rename a generated property and verify the agent patches the customization partial class"; + + /// + public override string[] Tags => ["dotnet", "customization", "patching", "poc"]; + + /// + public override RepoConfig Repo => new() + { + Owner = "Azure", + Name = "azure-sdk-for-net", + Ref = "main" + }; + + /// + public override string Prompt => $""" + The package at {PackageDir} has build errors after code regeneration. + A property was renamed in the generated code, breaking the customization file. + Use the CustomizedCodeUpdate tool to analyze the build errors and fix the customization files. + """; + + /// + public override TimeSpan Timeout => TimeSpan.FromMinutes(5); + + /// + public override async Task SetupAsync(Workspace workspace) + { + // Read the generated file and rename a commonly-referenced property + // to simulate a breaking regeneration change. + // The customization partial class should reference the old name and fail to compile. + if (File.Exists(Path.Combine(workspace.RepoPath, GeneratedFile))) + { + var content = await workspace.ReadFileAsync(GeneratedFile); + // Introduce a property rename that will break the customization file + content = content.Replace("AnalyzeDocument", "AnalyzeDocumentContent"); + await workspace.WriteFileAsync(GeneratedFile, content); + } + } + + /// + public override IEnumerable Validators => + [ + new FileExistsValidator("Customization file exists", + CustomizationFile), + + new ContainsValidator("Customization file references new property name", + filePath: CustomizationFile, + patterns: ["AnalyzeDocumentContent"]), + ]; +} 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..7369f567d20 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/Languages/DotnetLanguageServicePatchTests.cs @@ -0,0 +1,194 @@ +// 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_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 async Task ApplyPatchesAsync_CancellationRequested_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 OperationCanceledException()); + + var result = await _service.ApplyPatchesAsync( + customizationRoot, + _tempDir.DirectoryPath, + "error CS0117: some error", + CancellationToken.None); + + Assert.That(result, Is.Empty); + } +} 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 e2ad90ab409..0194748f201 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, 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 0c3ef5147ea..97ced7a43d1 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 46bf41c876d..74d45b4d56a 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 @@ -77,7 +77,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 7fc0d9db1fa..a8301013fb9 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 @@ -63,7 +63,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/Prompts/Templates/DotnetErrorDrivenPatchTemplate.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Prompts/Templates/DotnetErrorDrivenPatchTemplate.cs new file mode 100644 index 00000000000..1c81e2861b7 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Prompts/Templates/DotnetErrorDrivenPatchTemplate.cs @@ -0,0 +1,176 @@ +// 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 + Three 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`. + + ## 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 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 """ + ## CONSTRAINTS + + ### 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 + + ### 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. + + ### 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. + + """; + } + + private string BuildOutputRequirements() + { + return """ + ## OUTPUT + 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..c8ff05d78ac 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,101 @@ 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) + ] + }; + + // 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 (Exception ex) + { + logger.LogError(ex, "Failed to apply patches"); + return []; + } + } } From 553de7e7defaf565b43729c0149591c1b3b3e9b6 Mon Sep 17 00:00:00 2001 From: Maddy Redding Heaps Date: Fri, 13 Mar 2026 10:28:07 -0700 Subject: [PATCH 2/7] updates --- tools/azsdk-cli/Azure.Sdk.Tools.Cli/Program.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Program.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Program.cs index 073dc6ce790..1fe7c4057b0 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Program.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Program.cs @@ -56,6 +56,12 @@ public static WebApplicationBuilder CreateAppBuilder(string[] args, string outpu // Skip azure client and noisy third-party logging builder.Logging.AddFilter((category, level) => { + // Always suppress Copilot SDK noise, even in debug mode + if (category?.StartsWith("GitHub.Copilot.SDK", StringComparison.Ordinal) == true) + { + return level >= LogLevel.Warning; + } + if (debug || null == category) { return level >= logLevel; } var isAzureClient = category.StartsWith("Azure.", StringComparison.Ordinal); var isToolsClient = category.StartsWith("Azure.Sdk.Tools.", StringComparison.Ordinal); @@ -63,7 +69,6 @@ public static WebApplicationBuilder CreateAppBuilder(string[] args, string outpu // Suppress noisy third-party/framework categories if (category.StartsWith("System.Net.Http.HttpClient", StringComparison.Ordinal) - || category.StartsWith("GitHub.Copilot.SDK", StringComparison.Ordinal) || category.StartsWith("Microsoft.AspNetCore", StringComparison.Ordinal)) { return level >= LogLevel.Warning; From f8fba154f534e7a2949fa549b8bfe1cc7ce51b3a Mon Sep 17 00:00:00 2001 From: Maddy Redding Heaps Date: Tue, 17 Mar 2026 16:28:33 -0700 Subject: [PATCH 3/7] new tool --- .../CopilotAgents/Tools/FileToolsTests.cs | 137 ++++++++++++++++++ .../DotnetLanguageServicePatchTests.cs | 27 ++++ .../CopilotAgents/Tools/FileTools.cs | 55 +++++++ .../DotnetErrorDrivenPatchTemplate.cs | 18 ++- .../Languages/DotnetLanguageService.cs | 5 +- 5 files changed, 240 insertions(+), 2 deletions(-) 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/Services/Languages/DotnetLanguageServicePatchTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/Languages/DotnetLanguageServicePatchTests.cs index 7369f567d20..ac00bd326af 100644 --- 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 @@ -109,6 +109,33 @@ await File.WriteAllTextAsync( 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() { 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/Prompts/Templates/DotnetErrorDrivenPatchTemplate.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Prompts/Templates/DotnetErrorDrivenPatchTemplate.cs index 1c81e2861b7..c08c3020ae5 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Prompts/Templates/DotnetErrorDrivenPatchTemplate.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Prompts/Templates/DotnetErrorDrivenPatchTemplate.cs @@ -62,7 +62,7 @@ When generated code changes (e.g. a property is renamed, a method signature chan the customization partial classes may reference stale names and fail to compile. ## TOOLS & FILE PATHS - Three tools are available. They use DIFFERENT base directories: + Four 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. @@ -79,6 +79,12 @@ the customization partial classes may reference stale names and fail to compile. - 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 @@ -105,6 +111,16 @@ reference to a renamed or removed member from the generated type. - 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 (e.g., error CS0246 "type or namespace name could not be found"), + 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"`. + ### 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. 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 c8ff05d78ac..872c75b5605 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 @@ -444,7 +444,10 @@ public override async Task> ApplyPatchesAsync( 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) + 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))) ] }; From b1d5fcb642834fd79f7ddf764bc9946f5e4ada2e Mon Sep 17 00:00:00 2001 From: Maddy Redding Heaps Date: Tue, 17 Mar 2026 17:29:46 -0700 Subject: [PATCH 4/7] tweaks --- .../DotnetLanguageServicePatchTests.cs | 111 ++++++++++++++++++ .../DotnetErrorDrivenPatchTemplate.cs | 23 ++++ .../Languages/DotnetLanguageService.cs | 83 +++++++++++++ 3 files changed, 217 insertions(+) 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 index ac00bd326af..006402a3791 100644 --- 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 @@ -218,4 +218,115 @@ await File.WriteAllTextAsync( Assert.That(result, Is.Empty); } + + // ── RenameFilesToMatchClassNames tests ── + + [Test] + public void RenameFilesToMatchClassNames_RenamesFileWhenClassNameDiffers() + { + var customizationRoot = Path.Combine(_tempDir.DirectoryPath, "src"); + Directory.CreateDirectory(customizationRoot); + + var oldFile = Path.Combine(customizationRoot, "OldClient.cs"); + File.WriteAllText(oldFile, "public partial class NewClient { }"); + + var patches = new List + { + new("OldClient.cs", "Renamed class", 1) + }; + + _service.RenameFilesToMatchClassNames(customizationRoot, Path.DirectorySeparatorChar + "Generated" + Path.DirectorySeparatorChar, patches); + + Assert.That(File.Exists(Path.Combine(customizationRoot, "NewClient.cs")), Is.True); + Assert.That(File.Exists(oldFile), Is.False); + Assert.That(patches.Any(p => p.FilePath == "NewClient.cs"), Is.True); + } + + [Test] + public void RenameFilesToMatchClassNames_DoesNotRenameWhenNameAlreadyMatches() + { + var customizationRoot = Path.Combine(_tempDir.DirectoryPath, "src"); + Directory.CreateDirectory(customizationRoot); + + var file = Path.Combine(customizationRoot, "MyClient.cs"); + File.WriteAllText(file, "public partial class MyClient { }"); + + var patches = new List + { + new("MyClient.cs", "Some patch", 1) + }; + + _service.RenameFilesToMatchClassNames(customizationRoot, Path.DirectorySeparatorChar + "Generated" + Path.DirectorySeparatorChar, patches); + + Assert.That(File.Exists(file), Is.True); + Assert.That(patches.Count, Is.EqualTo(1)); // No rename patch added + } + + [Test] + public void RenameFilesToMatchClassNames_SkipsUnpatchedFiles() + { + var customizationRoot = Path.Combine(_tempDir.DirectoryPath, "src"); + Directory.CreateDirectory(customizationRoot); + + // This file was NOT patched by the agent + var file = Path.Combine(customizationRoot, "OldClient.cs"); + File.WriteAllText(file, "public partial class NewClient { }"); + + var patches = new List(); + + _service.RenameFilesToMatchClassNames(customizationRoot, Path.DirectorySeparatorChar + "Generated" + Path.DirectorySeparatorChar, patches); + + // File should NOT be renamed because it wasn't in the patch log + Assert.That(File.Exists(file), Is.True); + Assert.That(File.Exists(Path.Combine(customizationRoot, "NewClient.cs")), Is.False); + } + + [Test] + public void RenameFilesToMatchClassNames_RemovesDuplicateAndRenames() + { + var customizationRoot = Path.Combine(_tempDir.DirectoryPath, "src"); + Directory.CreateDirectory(customizationRoot); + + // Old file was patched (has real customization content) + var oldFile = Path.Combine(customizationRoot, "OldClient.cs"); + File.WriteAllText(oldFile, "public partial class NewClient\n{\n public void CustomMethod() { }\n}"); + + // Duplicate file already exists at the target name + var dupFile = Path.Combine(customizationRoot, "NewClient.cs"); + File.WriteAllText(dupFile, "public partial class NewClient { }"); + + var patches = new List + { + new("OldClient.cs", "Renamed class", 1) + }; + + _service.RenameFilesToMatchClassNames(customizationRoot, Path.DirectorySeparatorChar + "Generated" + Path.DirectorySeparatorChar, patches); + + Assert.That(File.Exists(Path.Combine(customizationRoot, "NewClient.cs")), Is.True); + Assert.That(File.Exists(oldFile), Is.False); + // The moved file should have the real content + var content = File.ReadAllText(Path.Combine(customizationRoot, "NewClient.cs")); + Assert.That(content, Does.Contain("CustomMethod")); + } + + [Test] + public void RenameFilesToMatchClassNames_SkipsGeneratedFiles() + { + var customizationRoot = Path.Combine(_tempDir.DirectoryPath, "src"); + var generatedDir = Path.Combine(customizationRoot, "Generated"); + Directory.CreateDirectory(generatedDir); + + var genFile = Path.Combine(generatedDir, "OldClient.cs"); + File.WriteAllText(genFile, "public partial class NewClient { }"); + + var patches = new List + { + new(Path.Combine("Generated", "OldClient.cs"), "Renamed class", 1) + }; + + _service.RenameFilesToMatchClassNames(customizationRoot, Path.DirectorySeparatorChar + "Generated" + Path.DirectorySeparatorChar, patches); + + // Generated files should be left alone + Assert.That(File.Exists(genFile), Is.True); + } } 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 index c08c3020ae5..6f8aa3b691f 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Prompts/Templates/DotnetErrorDrivenPatchTemplate.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Prompts/Templates/DotnetErrorDrivenPatchTemplate.cs @@ -120,6 +120,11 @@ When a class or type is renamed (e.g., error CS0246 "type or namespace name coul - 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. @@ -144,6 +149,7 @@ Apply patches only when the fix is obvious and correct. - 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**: @@ -164,6 +170,10 @@ Apply patches only when the fix is obvious and correct. 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. @@ -174,6 +184,19 @@ Apply patches only when the fix is obvious and correct. - 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. + """; } 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 872c75b5605..8cc3758b06b 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 @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Text.Json; +using System.Text.RegularExpressions; using Azure.Sdk.Tools.Cli.CopilotAgents; using Azure.Sdk.Tools.Cli.CopilotAgents.Tools; using Azure.Sdk.Tools.Cli.Helpers; @@ -469,6 +470,10 @@ public override async Task> ApplyPatchesAsync( // The patchLog was populated directly by the tool on each successful patch var appliedPatches = patchLog.ToList(); + // Post-processing: ensure customization file names match their class declarations. + // The agent may rename the class inside a file but fail to rename the file itself. + RenameFilesToMatchClassNames(customizationRoot, generatedDirMarker, appliedPatches); + logger.LogInformation("Patch application completed, patches applied: {PatchCount}", appliedPatches.Count); return appliedPatches; } @@ -478,4 +483,82 @@ public override async Task> ApplyPatchesAsync( return []; } } + + // Matches "partial class ClassName" — captures the class name. + private static readonly Regex PartialClassRegex = new(@"\bpartial\s+class\s+(\w+)", RegexOptions.Compiled); + + /// + /// Scans customization files that were patched and renames any whose file name no longer + /// matches the primary partial class declaration inside. This is a safety net for cases + /// where the agent renames the class but does not call the RenameFile tool. + /// + public void RenameFilesToMatchClassNames( + string customizationRoot, + string generatedDirMarker, + List appliedPatches) + { + try + { + // Build a set of files the agent touched (relative paths from patch log) + var patchedRelPaths = new HashSet( + appliedPatches.Select(p => p.FilePath), + StringComparer.OrdinalIgnoreCase); + + var csFiles = Directory.GetFiles(customizationRoot, "*.cs", SearchOption.AllDirectories) + .Where(f => !f.Contains(generatedDirMarker, StringComparison.OrdinalIgnoreCase)); + + foreach (var filePath in csFiles) + { + var relPath = Path.GetRelativePath(customizationRoot, filePath); + + // Only consider files the agent actually patched + if (!patchedRelPaths.Contains(relPath)) + { + continue; + } + + var content = File.ReadAllText(filePath); + var match = PartialClassRegex.Match(content); + if (!match.Success) + { + continue; + } + + var className = match.Groups[1].Value; + var currentName = Path.GetFileNameWithoutExtension(filePath); + + if (string.Equals(currentName, className, StringComparison.Ordinal)) + { + continue; // Already matches + } + + var dir = Path.GetDirectoryName(filePath)!; + var targetPath = Path.Combine(dir, className + ".cs"); + + if (File.Exists(targetPath)) + { + // Target already exists — it may be a duplicate the agent created. + // Keep the patched original (which has the real customization content) + // and remove the duplicate target so we can move the original there. + logger.LogInformation( + "Removing duplicate file {Target} to make room for rename from {Source}", + targetPath, filePath); + File.Delete(targetPath); + } + + File.Move(filePath, targetPath); + var targetRelPath = Path.GetRelativePath(customizationRoot, targetPath); + appliedPatches.Add(new AppliedPatch( + targetRelPath, + $"Renamed file from {currentName}.cs to {className}.cs to match class name", + 1)); + + logger.LogInformation("Auto-renamed {Old} → {New} to match partial class name", relPath, targetRelPath); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error during post-patch file rename check"); + } + } } From d98787318ed3d209ad669edec2afa18c091381f3 Mon Sep 17 00:00:00 2001 From: Maddy Redding Heaps Date: Tue, 17 Mar 2026 17:50:41 -0700 Subject: [PATCH 5/7] more updates --- .../DotnetPatchCustomizationScenario.cs | 79 ------------- .../Validators/VerifyResultWithAIValidate.cs | 4 +- .../DotnetLanguageServicePatchTests.cs | 111 ------------------ .../DotnetErrorDrivenPatchTemplate.cs | 5 +- .../Languages/DotnetLanguageService.cs | 83 ------------- 5 files changed, 4 insertions(+), 278 deletions(-) delete mode 100644 tools/azsdk-cli/Azure.Sdk.Tools.Cli.Benchmarks/Scenarios/Typespec/DotnetPatchCustomizationScenario.cs diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Benchmarks/Scenarios/Typespec/DotnetPatchCustomizationScenario.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Benchmarks/Scenarios/Typespec/DotnetPatchCustomizationScenario.cs deleted file mode 100644 index df34db3e5e7..00000000000 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Benchmarks/Scenarios/Typespec/DotnetPatchCustomizationScenario.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using Azure.Sdk.Tools.Cli.Benchmarks.Infrastructure; -using Azure.Sdk.Tools.Cli.Benchmarks.Models; -using Azure.Sdk.Tools.Cli.Benchmarks.Validation; -using Azure.Sdk.Tools.Cli.Benchmarks.Validation.Validators; - -namespace Azure.Sdk.Tools.Cli.Benchmarks.Scenarios; - -/// -/// Scenario that tests the .NET error-driven patch template by introducing a breaking -/// property rename in a generated type and verifying that the agent patches the -/// customization file to use the new name. -/// -/// Target: Azure.AI.DocumentIntelligence in azure-sdk-for-net. -/// The setup renames a property in the generated code to simulate a regeneration -/// breaking change, then the agent should patch the customization partial class. -/// -public class DotnetPatchCustomizationScenario : BenchmarkScenario -{ - private const string PackageDir = "sdk/documentintelligence/Azure.AI.DocumentIntelligence"; - private const string CustomizationFile = $"{PackageDir}/src/DocumentIntelligenceClient.cs"; - private const string GeneratedFile = $"{PackageDir}/src/Generated/DocumentIntelligenceClient.cs"; - - /// - public override string Name => "dotnet-patch-customization"; - - /// - public override string Description => - "Test .NET error-driven patching: rename a generated property and verify the agent patches the customization partial class"; - - /// - public override string[] Tags => ["dotnet", "customization", "patching", "poc"]; - - /// - public override RepoConfig Repo => new() - { - Owner = "Azure", - Name = "azure-sdk-for-net", - Ref = "main" - }; - - /// - public override string Prompt => $""" - The package at {PackageDir} has build errors after code regeneration. - A property was renamed in the generated code, breaking the customization file. - Use the CustomizedCodeUpdate tool to analyze the build errors and fix the customization files. - """; - - /// - public override TimeSpan Timeout => TimeSpan.FromMinutes(5); - - /// - public override async Task SetupAsync(Workspace workspace) - { - // Read the generated file and rename a commonly-referenced property - // to simulate a breaking regeneration change. - // The customization partial class should reference the old name and fail to compile. - if (File.Exists(Path.Combine(workspace.RepoPath, GeneratedFile))) - { - var content = await workspace.ReadFileAsync(GeneratedFile); - // Introduce a property rename that will break the customization file - content = content.Replace("AnalyzeDocument", "AnalyzeDocumentContent"); - await workspace.WriteFileAsync(GeneratedFile, content); - } - } - - /// - public override IEnumerable Validators => - [ - new FileExistsValidator("Customization file exists", - CustomizationFile), - - new ContainsValidator("Customization file references new property name", - filePath: CustomizationFile, - patterns: ["AnalyzeDocumentContent"]), - ]; -} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Benchmarks/Validation/Validators/VerifyResultWithAIValidate.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Benchmarks/Validation/Validators/VerifyResultWithAIValidate.cs index 5a90d5093d9..e09d7dc8f57 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Benchmarks/Validation/Validators/VerifyResultWithAIValidate.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Benchmarks/Validation/Validators/VerifyResultWithAIValidate.cs @@ -60,13 +60,13 @@ public async Task ValidateAsync(ValidationContext context, Can } }; - await using var session = await client.CreateSessionAsync(sessionConfig); + await using var session = await client.CreateSessionAsync(sessionConfig, cancellationToken); SessionConfigHelper.ConfigureAgentActivityLogging(session); // Send prompt and wait for completion var messageOptions = new MessageOptions { Prompt = VerificationPrompt }; - var result = await session.SendAndWaitAsync(messageOptions, TimeSpan.FromMinutes(5)); + var result = await session.SendAndWaitAsync(messageOptions, TimeSpan.FromMinutes(5), cancellationToken); if (result == null || result.Data == null || string.IsNullOrEmpty(result.Data.Content)) { 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 index 006402a3791..ac00bd326af 100644 --- 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 @@ -218,115 +218,4 @@ await File.WriteAllTextAsync( Assert.That(result, Is.Empty); } - - // ── RenameFilesToMatchClassNames tests ── - - [Test] - public void RenameFilesToMatchClassNames_RenamesFileWhenClassNameDiffers() - { - var customizationRoot = Path.Combine(_tempDir.DirectoryPath, "src"); - Directory.CreateDirectory(customizationRoot); - - var oldFile = Path.Combine(customizationRoot, "OldClient.cs"); - File.WriteAllText(oldFile, "public partial class NewClient { }"); - - var patches = new List - { - new("OldClient.cs", "Renamed class", 1) - }; - - _service.RenameFilesToMatchClassNames(customizationRoot, Path.DirectorySeparatorChar + "Generated" + Path.DirectorySeparatorChar, patches); - - Assert.That(File.Exists(Path.Combine(customizationRoot, "NewClient.cs")), Is.True); - Assert.That(File.Exists(oldFile), Is.False); - Assert.That(patches.Any(p => p.FilePath == "NewClient.cs"), Is.True); - } - - [Test] - public void RenameFilesToMatchClassNames_DoesNotRenameWhenNameAlreadyMatches() - { - var customizationRoot = Path.Combine(_tempDir.DirectoryPath, "src"); - Directory.CreateDirectory(customizationRoot); - - var file = Path.Combine(customizationRoot, "MyClient.cs"); - File.WriteAllText(file, "public partial class MyClient { }"); - - var patches = new List - { - new("MyClient.cs", "Some patch", 1) - }; - - _service.RenameFilesToMatchClassNames(customizationRoot, Path.DirectorySeparatorChar + "Generated" + Path.DirectorySeparatorChar, patches); - - Assert.That(File.Exists(file), Is.True); - Assert.That(patches.Count, Is.EqualTo(1)); // No rename patch added - } - - [Test] - public void RenameFilesToMatchClassNames_SkipsUnpatchedFiles() - { - var customizationRoot = Path.Combine(_tempDir.DirectoryPath, "src"); - Directory.CreateDirectory(customizationRoot); - - // This file was NOT patched by the agent - var file = Path.Combine(customizationRoot, "OldClient.cs"); - File.WriteAllText(file, "public partial class NewClient { }"); - - var patches = new List(); - - _service.RenameFilesToMatchClassNames(customizationRoot, Path.DirectorySeparatorChar + "Generated" + Path.DirectorySeparatorChar, patches); - - // File should NOT be renamed because it wasn't in the patch log - Assert.That(File.Exists(file), Is.True); - Assert.That(File.Exists(Path.Combine(customizationRoot, "NewClient.cs")), Is.False); - } - - [Test] - public void RenameFilesToMatchClassNames_RemovesDuplicateAndRenames() - { - var customizationRoot = Path.Combine(_tempDir.DirectoryPath, "src"); - Directory.CreateDirectory(customizationRoot); - - // Old file was patched (has real customization content) - var oldFile = Path.Combine(customizationRoot, "OldClient.cs"); - File.WriteAllText(oldFile, "public partial class NewClient\n{\n public void CustomMethod() { }\n}"); - - // Duplicate file already exists at the target name - var dupFile = Path.Combine(customizationRoot, "NewClient.cs"); - File.WriteAllText(dupFile, "public partial class NewClient { }"); - - var patches = new List - { - new("OldClient.cs", "Renamed class", 1) - }; - - _service.RenameFilesToMatchClassNames(customizationRoot, Path.DirectorySeparatorChar + "Generated" + Path.DirectorySeparatorChar, patches); - - Assert.That(File.Exists(Path.Combine(customizationRoot, "NewClient.cs")), Is.True); - Assert.That(File.Exists(oldFile), Is.False); - // The moved file should have the real content - var content = File.ReadAllText(Path.Combine(customizationRoot, "NewClient.cs")); - Assert.That(content, Does.Contain("CustomMethod")); - } - - [Test] - public void RenameFilesToMatchClassNames_SkipsGeneratedFiles() - { - var customizationRoot = Path.Combine(_tempDir.DirectoryPath, "src"); - var generatedDir = Path.Combine(customizationRoot, "Generated"); - Directory.CreateDirectory(generatedDir); - - var genFile = Path.Combine(generatedDir, "OldClient.cs"); - File.WriteAllText(genFile, "public partial class NewClient { }"); - - var patches = new List - { - new(Path.Combine("Generated", "OldClient.cs"), "Renamed class", 1) - }; - - _service.RenameFilesToMatchClassNames(customizationRoot, Path.DirectorySeparatorChar + "Generated" + Path.DirectorySeparatorChar, patches); - - // Generated files should be left alone - Assert.That(File.Exists(genFile), Is.True); - } } 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 index 6f8aa3b691f..ed3c0bf7aa2 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Prompts/Templates/DotnetErrorDrivenPatchTemplate.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Prompts/Templates/DotnetErrorDrivenPatchTemplate.cs @@ -112,9 +112,8 @@ reference to a renamed or removed member from the generated type. - 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 (e.g., error CS0246 "type or namespace name could not be found"), - 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. + 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. 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 8cc3758b06b..872c75b5605 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 @@ -3,7 +3,6 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Text.Json; -using System.Text.RegularExpressions; using Azure.Sdk.Tools.Cli.CopilotAgents; using Azure.Sdk.Tools.Cli.CopilotAgents.Tools; using Azure.Sdk.Tools.Cli.Helpers; @@ -470,10 +469,6 @@ public override async Task> ApplyPatchesAsync( // The patchLog was populated directly by the tool on each successful patch var appliedPatches = patchLog.ToList(); - // Post-processing: ensure customization file names match their class declarations. - // The agent may rename the class inside a file but fail to rename the file itself. - RenameFilesToMatchClassNames(customizationRoot, generatedDirMarker, appliedPatches); - logger.LogInformation("Patch application completed, patches applied: {PatchCount}", appliedPatches.Count); return appliedPatches; } @@ -483,82 +478,4 @@ public override async Task> ApplyPatchesAsync( return []; } } - - // Matches "partial class ClassName" — captures the class name. - private static readonly Regex PartialClassRegex = new(@"\bpartial\s+class\s+(\w+)", RegexOptions.Compiled); - - /// - /// Scans customization files that were patched and renames any whose file name no longer - /// matches the primary partial class declaration inside. This is a safety net for cases - /// where the agent renames the class but does not call the RenameFile tool. - /// - public void RenameFilesToMatchClassNames( - string customizationRoot, - string generatedDirMarker, - List appliedPatches) - { - try - { - // Build a set of files the agent touched (relative paths from patch log) - var patchedRelPaths = new HashSet( - appliedPatches.Select(p => p.FilePath), - StringComparer.OrdinalIgnoreCase); - - var csFiles = Directory.GetFiles(customizationRoot, "*.cs", SearchOption.AllDirectories) - .Where(f => !f.Contains(generatedDirMarker, StringComparison.OrdinalIgnoreCase)); - - foreach (var filePath in csFiles) - { - var relPath = Path.GetRelativePath(customizationRoot, filePath); - - // Only consider files the agent actually patched - if (!patchedRelPaths.Contains(relPath)) - { - continue; - } - - var content = File.ReadAllText(filePath); - var match = PartialClassRegex.Match(content); - if (!match.Success) - { - continue; - } - - var className = match.Groups[1].Value; - var currentName = Path.GetFileNameWithoutExtension(filePath); - - if (string.Equals(currentName, className, StringComparison.Ordinal)) - { - continue; // Already matches - } - - var dir = Path.GetDirectoryName(filePath)!; - var targetPath = Path.Combine(dir, className + ".cs"); - - if (File.Exists(targetPath)) - { - // Target already exists — it may be a duplicate the agent created. - // Keep the patched original (which has the real customization content) - // and remove the duplicate target so we can move the original there. - logger.LogInformation( - "Removing duplicate file {Target} to make room for rename from {Source}", - targetPath, filePath); - File.Delete(targetPath); - } - - File.Move(filePath, targetPath); - var targetRelPath = Path.GetRelativePath(customizationRoot, targetPath); - appliedPatches.Add(new AppliedPatch( - targetRelPath, - $"Renamed file from {currentName}.cs to {className}.cs to match class name", - 1)); - - logger.LogInformation("Auto-renamed {Old} → {New} to match partial class name", relPath, targetRelPath); - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Error during post-patch file rename check"); - } - } } From 31f5ca54a9e599e6f801831bfbbc66742a815d9f Mon Sep 17 00:00:00 2001 From: Maddy Redding Heaps Date: Wed, 18 Mar 2026 10:09:56 -0700 Subject: [PATCH 6/7] bug fix --- .../DotnetLanguageServicePatchTests.cs | 17 ++++++++--------- .../Templates/DotnetErrorDrivenPatchTemplate.cs | 4 +--- .../Services/Languages/DotnetLanguageService.cs | 1 + 3 files changed, 10 insertions(+), 12 deletions(-) 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 index ac00bd326af..4831b2c4d4e 100644 --- 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 @@ -198,11 +198,11 @@ await _service.ApplyPatchesAsync( } [Test] - public async Task ApplyPatchesAsync_CancellationRequested_ReturnsEmptyList() + public void ApplyPatchesAsync_CancellationRequested_Throws() { var customizationRoot = Path.Combine(_tempDir.DirectoryPath, "src"); Directory.CreateDirectory(customizationRoot); - await File.WriteAllTextAsync( + File.WriteAllText( Path.Combine(customizationRoot, "Test.cs"), "public partial class Test { }"); @@ -210,12 +210,11 @@ await File.WriteAllTextAsync( .Setup(r => r.RunAsync(It.IsAny>(), It.IsAny())) .ThrowsAsync(new OperationCanceledException()); - var result = await _service.ApplyPatchesAsync( - customizationRoot, - _tempDir.DirectoryPath, - "error CS0117: some error", - CancellationToken.None); - - Assert.That(result, Is.Empty); + Assert.ThrowsAsync(() => + _service.ApplyPatchesAsync( + customizationRoot, + _tempDir.DirectoryPath, + "error CS0117: some error", + CancellationToken.None)); } } 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 index ed3c0bf7aa2..159389b6ce3 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Prompts/Templates/DotnetErrorDrivenPatchTemplate.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Prompts/Templates/DotnetErrorDrivenPatchTemplate.cs @@ -62,7 +62,7 @@ When generated code changes (e.g. a property is renamed, a method signature chan the customization partial classes may reference stale names and fail to compile. ## TOOLS & FILE PATHS - Four tools are available. They use DIFFERENT base directories: + 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. @@ -135,7 +135,6 @@ the generated partial class again. private string BuildTaskConstraints() { return """ - ## CONSTRAINTS ### 1. CUSTOMIZATION FILES ONLY You may patch ONLY the customization files provided. Never patch generated code in the `Generated/` folder. @@ -202,7 +201,6 @@ resolve automatically once the partial class name is corrected to match the gene private string BuildOutputRequirements() { return """ - ## OUTPUT Return a brief summary of what you did: - If patches were applied: describe each fix - If no patches could be applied: return empty string "" 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 872c75b5605..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 @@ -472,6 +472,7 @@ public override async Task> ApplyPatchesAsync( 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"); From ea74901db77772a065ca2a05be0460070c4d2f9b Mon Sep 17 00:00:00 2001 From: Maddy Heaps <66138537+m-redding@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:15:13 -0700 Subject: [PATCH 7/7] Remove Copilot SDK noise suppression Removed suppression of Copilot SDK logging in debug mode. --- tools/azsdk-cli/Azure.Sdk.Tools.Cli/Program.cs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Program.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Program.cs index 9923b166dbd..e260627fe6e 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Program.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Program.cs @@ -56,12 +56,6 @@ public static WebApplicationBuilder CreateAppBuilder(string[] args, string outpu // Skip azure client and noisy third-party logging builder.Logging.AddFilter((category, level) => { - // Always suppress Copilot SDK noise, even in debug mode - if (category?.StartsWith("GitHub.Copilot.SDK", StringComparison.Ordinal) == true) - { - return level >= LogLevel.Warning; - } - if (debug || null == category) { return level >= logLevel; } var isAzureClient = category.StartsWith("Azure.", StringComparison.Ordinal); var isToolsClient = category.StartsWith("Azure.Sdk.Tools.", StringComparison.Ordinal); @@ -69,6 +63,7 @@ public static WebApplicationBuilder CreateAppBuilder(string[] args, string outpu // Suppress noisy third-party/framework categories if (category.StartsWith("System.Net.Http.HttpClient", StringComparison.Ordinal) + || category.StartsWith("GitHub.Copilot.SDK", StringComparison.Ordinal) || category.StartsWith("Microsoft.AspNetCore", StringComparison.Ordinal)) { return level >= LogLevel.Warning; @@ -112,4 +107,4 @@ public static WebApplicationBuilder CreateAppBuilder(string[] args, string outpu return builder; } -} \ No newline at end of file +}