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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
using System.ClientModel;
using System.ClientModel.Primitives;
using System.CommandLine;
using System.CommandLine.Parsing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using Azure.AI.OpenAI;
using OpenAI.Chat;
using Azure.Sdk.Tools.Cli.Helpers;
using Azure.Sdk.Tools.Cli.Microagents;
using Azure.Sdk.Tools.Cli.Services;
using Azure.Sdk.Tools.Cli.Tests.Mocks.Helpers;
using Azure.Sdk.Tools.Cli.Tests.TestHelpers;
using Azure.Sdk.Tools.Cli.Tools.Package;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using OpenAI.Chat;

namespace Azure.Sdk.Tools.Cli.Tests.Tools.Generators
{
Expand All @@ -20,7 +19,7 @@ internal class ReadMeGeneratorToolTests
[Test]
public async Task TestReadmeGeneratorTool()
{
var testClients = SetupOpenAIMocks();
var testClients = SetupMocks();
(DirectoryInfo root, string packagePath) = await CreateFakeLanguageRepo();

var readmeOutputPath = Path.GetTempFileName();
Expand Down Expand Up @@ -84,6 +83,51 @@ public void TestReadmeGeneratorToolLive()
});
}

/// <summary>
/// These tests all correspond to "LLM needs to do more work" type of returns from the tool, like removing
/// hardcoded locales, like 'en-us', or having it regenerate if some of the templates tokens make it through
/// and weren't replaced.
/// </summary>
/// <returns></returns>
[TestCase(
"Bad link, still has locale in it: https://learn.microsoft.com/fr-fr/blahblah",
"The readme contains links with locales. Keep the link, but remove these locales from links: (fr-fr).", TestName = "Links with locales in them")]
[TestCase(
"Still referencing aztemplate and (package path)",
"The readme contains placeholders (aztemplate,(package path)) that should be removed and replaced with a proper package name")]
[TestCase(
"Still referencing placeholder (package path)",
"The readme contains placeholders ((package path)) that should be removed and replaced with a proper package name")]
public async Task TestBadReadmeContent(string readmeContent, string expectedFeedback)
{
var testClients = SetupMocks(readmeContent);
(DirectoryInfo root, string packagePath) = await CreateFakeLanguageRepo();

var readmeOutputPath = Path.GetTempFileName();
var readmeTemplatePath = Path.Combine(AppContext.BaseDirectory, "TestAssets", "README-template.go.md");

try
{
var tool = ActivatorUtilities.CreateInstance<ReadMeGeneratorTool>(testClients.ServiceProvider);
var command = tool.GetCommand();

int exitCode = command.Invoke($"--output-path \"{readmeOutputPath}\" --service-url \"https://learn.microsoft.com/azure/service-bus-messaging\" --template-path {readmeTemplatePath} --package-path {packagePath}");

Assert.Multiple(() =>
{
Assert.That(exitCode, Is.EqualTo(1), "Command should fail, as the final readme doesn't pass validation");
Assert.That(testClients.OutputHelper.Outputs.First().Method, Is.EqualTo("OutputError"));
Assert.That(testClients.OutputHelper.Outputs.First().OutputValue, Is.EqualTo($"ReadmeGenerator failed with validation errors: {expectedFeedback}"));
});

Assert.That(File.Exists(readmeOutputPath), Is.True, "Readme output file should be created");
}
finally
{
Directory.Delete(root.FullName, true);
}
}

/// <summary>
/// Creates a service provider in the same way as the normal program. Can be used to instantiate real clients.
/// </summary>
Expand All @@ -107,34 +151,19 @@ private static (ServiceProvider, MockOutputHelper) CreateServiceProvider(Action<
return (sp, OutputHelper);
}

private static TestClients SetupOpenAIMocks()
private static TestClients SetupMocks(string readmeContents = "This is a test response for the readme generation.")
{
var (openAIClientMock, chatClientMock) = OpenAIMockHelper.Create("gpt-4.1");

// basically - create a model using the appropriate OpenAI*ModelFactory
// then return it, wrapped in a ClientResult. This is really similar to the online samples
// except it's ClientResult (instead of Response).
var chatCompletion = OpenAIChatModelFactory.ChatCompletion(
content: new ChatMessageContent("This is a test response for the readme generation.")
);

chatClientMock
.Setup(ccm => ccm.CompleteChatAsync(It.IsAny<ChatMessage[]>())) // NOTE: I'm not checking the chat message input - I already know what I'm sending.
.Returns(() =>
{
return Task.FromResult(
ClientResult.FromValue(chatCompletion, Mock.Of<PipelineResponse>())
);
});
var serviceMock = new Mock<IMicroagentHostService>();
serviceMock.Setup(svc => svc.RunAgentToCompletion(
It.IsAny<Microagent<ReadmeGenerator.ReadmeContents>>(), It.IsAny<CancellationToken>())
).Returns(() => Task.FromResult(new ReadmeGenerator.ReadmeContents(readmeContents)));

var (serviceProvider, OutputHelper) = CreateServiceProvider((sc) =>
{
sc.AddLogging((lb) => lb.AddConsole());
sc.AddSingleton(openAIClientMock.Object);

// register the mocks too, if you want to grab them later.
sc.AddSingleton(chatClientMock);
sc.AddSingleton(openAIClientMock);
sc.AddSingleton(serviceMock.Object);
});

return new TestClients(openAIClientMock, chatClientMock, serviceProvider, OutputHelper);
Expand Down
33 changes: 33 additions & 0 deletions tools/azsdk-cli/Azure.Sdk.Tools.Cli/Microagents/AgentTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,37 @@ public async Task<string> Invoke(string input, CancellationToken ct = default)
}

public abstract Task<TOutput> Invoke(TInput input, CancellationToken ct);

/// <summary>
/// Creates an AgentTool that wraps a function to invoke, rather than requiring a class.
/// </summary>
/// <param name="name">The name of the tool</param>
/// <param name="description">The description of the tool</param>
/// <param name="invokeHandler">The function to invoke when this tool is used.</param>
/// <returns></returns>
public static AgentTool<TInput, TOutput> FromFunc(string name, string description, Func<TInput, CancellationToken, Task<TOutput>> invokeHandler)
{
return new FuncAgentTool<TInput, TOutput>(name, description, invokeHandler);
}

/// <summary>
/// An AgentTool that wraps a function to invoke, rather than requiring a class.
/// </summary>
/// <typeparam name="TInput">Input schema type</typeparam>
/// <typeparam name="TOutput">Output schema type</typeparam>
/// <param name="name">The name of the tool</param>
/// <param name="description">The description of the tool</param>
/// <param name="invokeHandler">The function to invoke when this tool is used.</param>
private class FuncAgentTool<ToolInputT, ToolOutputT>(string name, string description, Func<ToolInputT, CancellationToken, Task<ToolOutputT>> invokeHandler) : AgentTool<ToolInputT, ToolOutputT>
{
private readonly Func<ToolInputT, CancellationToken, Task<ToolOutputT>> invoke = invokeHandler;

public override string Name { get; init; } = name;
public override string Description { get; init; } = description;

public override Task<ToolOutputT> Invoke(ToolInputT input, CancellationToken ct)
{
return invoke(input, ct);
}
}
}
Loading
Loading