mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into copilot/add-preconfigured-compaction-strategy
This commit is contained in:
+110
@@ -5,6 +5,8 @@ using System.Reflection;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
@@ -235,6 +237,114 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowMcpToolSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "04_WorkflowMcpTool");
|
||||
await this.RunSampleTestAsync(samplePath, requiresOpenAI: false, async (logs) =>
|
||||
{
|
||||
// Connect to the MCP endpoint exposed by the Azure Functions host
|
||||
IClientTransport clientTransport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new Uri($"http://localhost:{AzureFunctionsPort}/runtime/webhooks/mcp")
|
||||
});
|
||||
|
||||
await using McpClient mcpClient = await McpClient.CreateAsync(clientTransport);
|
||||
|
||||
// Verify both workflow tools are listed
|
||||
IList<McpClientTool> tools = await mcpClient.ListToolsAsync();
|
||||
this._outputHelper.WriteLine($"MCP tools found: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||
|
||||
Assert.Single(tools, t => t.Name == "Translate");
|
||||
Assert.Single(tools, t => t.Name == "OrderLookup");
|
||||
|
||||
// Invoke the Translate workflow via MCP tool (returns a string result)
|
||||
this._outputHelper.WriteLine("Invoking MCP tool 'Translate'...");
|
||||
CallToolResult translateResult = await mcpClient.CallToolAsync(
|
||||
"Translate",
|
||||
arguments: new Dictionary<string, object?> { { "input", "hello world" } });
|
||||
|
||||
Assert.NotEmpty(translateResult.Content);
|
||||
string translateResponse = Assert.IsType<TextContentBlock>(translateResult.Content[0]).Text;
|
||||
this._outputHelper.WriteLine($"Translate MCP tool response: {translateResponse}");
|
||||
Assert.NotEmpty(translateResponse);
|
||||
Assert.Contains("HELLO WORLD", translateResponse);
|
||||
|
||||
// Invoke the OrderLookup workflow via MCP tool (returns a POCO serialized as JSON)
|
||||
this._outputHelper.WriteLine("Invoking MCP tool 'OrderLookup'...");
|
||||
CallToolResult orderResult = await mcpClient.CallToolAsync(
|
||||
"OrderLookup",
|
||||
arguments: new Dictionary<string, object?> { { "input", "ORD-2025-42" } });
|
||||
|
||||
Assert.NotEmpty(orderResult.Content);
|
||||
string orderResponse = Assert.IsType<TextContentBlock>(orderResult.Content[0]).Text;
|
||||
this._outputHelper.WriteLine($"OrderLookup MCP tool response: {orderResponse}");
|
||||
Assert.NotEmpty(orderResponse);
|
||||
Assert.Contains("ORD-2025-42", orderResponse);
|
||||
|
||||
// Verify executor activities ran in the logs
|
||||
lock (logs)
|
||||
{
|
||||
Assert.True(logs.Any(log => log.Message.Contains("[Activity] TranslateText:")), "TranslateText activity not found in logs.");
|
||||
Assert.True(logs.Any(log => log.Message.Contains("[Activity] FormatOutput:")), "FormatOutput activity not found in logs.");
|
||||
Assert.True(logs.Any(log => log.Message.Contains("[Activity] LookupOrder:")), "LookupOrder activity not found in logs.");
|
||||
Assert.True(logs.Any(log => log.Message.Contains("[Activity] EnrichOrder:")), "EnrichOrder activity not found in logs.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowAndAgentsSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "05_WorkflowAndAgents");
|
||||
await this.RunSampleTestAsync(samplePath, requiresOpenAI: true, async (logs) =>
|
||||
{
|
||||
// Connect to the MCP endpoint exposed by the Azure Functions host
|
||||
IClientTransport clientTransport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new Uri($"http://localhost:{AzureFunctionsPort}/runtime/webhooks/mcp")
|
||||
});
|
||||
|
||||
await using McpClient mcpClient = await McpClient.CreateAsync(clientTransport);
|
||||
|
||||
// Verify both the agent and workflow tools are listed
|
||||
IList<McpClientTool> tools = await mcpClient.ListToolsAsync();
|
||||
this._outputHelper.WriteLine($"MCP tools found: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||
|
||||
Assert.Single(tools, t => t.Name == "Assistant");
|
||||
Assert.Single(tools, t => t.Name == "Translate");
|
||||
|
||||
// Invoke the Translate workflow via MCP tool
|
||||
this._outputHelper.WriteLine("Invoking MCP tool 'Translate'...");
|
||||
CallToolResult translateResult = await mcpClient.CallToolAsync(
|
||||
"Translate",
|
||||
arguments: new Dictionary<string, object?> { { "input", "hello world" } });
|
||||
|
||||
Assert.NotEmpty(translateResult.Content);
|
||||
string translateResponse = Assert.IsType<TextContentBlock>(translateResult.Content[0]).Text;
|
||||
this._outputHelper.WriteLine($"Translate MCP tool response: {translateResponse}");
|
||||
Assert.Contains("HELLO WORLD", translateResponse);
|
||||
|
||||
// Invoke the Assistant agent via MCP tool
|
||||
this._outputHelper.WriteLine("Invoking MCP tool 'Assistant'...");
|
||||
CallToolResult assistantResult = await mcpClient.CallToolAsync(
|
||||
"Assistant",
|
||||
arguments: new Dictionary<string, object?> { { "query", "What is 2 + 2?" } });
|
||||
|
||||
Assert.NotEmpty(assistantResult.Content);
|
||||
string assistantResponse = Assert.IsType<TextContentBlock>(assistantResult.Content[0]).Text;
|
||||
this._outputHelper.WriteLine($"Assistant MCP tool response: {assistantResponse}");
|
||||
Assert.NotEmpty(assistantResponse);
|
||||
|
||||
// Verify workflow executor activities ran in the logs
|
||||
lock (logs)
|
||||
{
|
||||
Assert.True(logs.Any(log => log.Message.Contains("[Activity] TranslateText:")), "TranslateText activity not found in logs.");
|
||||
Assert.True(logs.Any(log => log.Message.Contains("[Activity] FormatOutput:")), "FormatOutput activity not found in logs.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentWorkflowSampleValidationAsync()
|
||||
{
|
||||
|
||||
+39
@@ -148,6 +148,45 @@ public sealed class DurableAgentFunctionMetadataTransformerTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transform_SkipsAgents_WithoutExplicitOptions()
|
||||
{
|
||||
// Arrange: two agents in the dictionary, but only one has explicit FunctionsAgentOptions.
|
||||
// This simulates a workflow-auto-registered agent (workflowAgent) alongside a standalone agent.
|
||||
Dictionary<string, Func<IServiceProvider, AIAgent>> agents = new()
|
||||
{
|
||||
{ "standaloneAgent", _ => new TestAgent("standaloneAgent", "Standalone agent") },
|
||||
{ "workflowAgent", _ => new TestAgent("workflowAgent", "Auto-registered by workflow") }
|
||||
};
|
||||
|
||||
FunctionsAgentOptions standaloneOptions = new();
|
||||
standaloneOptions.HttpTrigger.IsEnabled = true;
|
||||
|
||||
// Only standaloneAgent has explicit options; workflowAgent does not.
|
||||
IFunctionsAgentOptionsProvider agentOptionsProvider = new FakeOptionsProvider(new Dictionary<string, FunctionsAgentOptions>
|
||||
{
|
||||
{ "standaloneAgent", standaloneOptions }
|
||||
});
|
||||
|
||||
List<IFunctionMetadata> metadataList = [];
|
||||
|
||||
DurableAgentFunctionMetadataTransformer transformer = new(
|
||||
agents,
|
||||
NullLogger<DurableAgentFunctionMetadataTransformer>.Instance,
|
||||
new FakeServiceProvider(),
|
||||
agentOptionsProvider);
|
||||
|
||||
// Act
|
||||
transformer.Transform(metadataList);
|
||||
|
||||
// Assert: only standaloneAgent should have triggers (entity + http = 2).
|
||||
// workflowAgent should be skipped entirely.
|
||||
Assert.Equal(2, metadataList.Count);
|
||||
Assert.Contains(metadataList, m => m.Name == "dafx-standaloneAgent");
|
||||
Assert.Contains(metadataList, m => m.Name == "http-standaloneAgent");
|
||||
Assert.DoesNotContain(metadataList, m => m.Name!.Contains("workflowAgent"));
|
||||
}
|
||||
|
||||
private static List<IFunctionMetadata> BuildFunctionMetadataList(int numberOfFunctions)
|
||||
{
|
||||
List<IFunctionMetadata> list = [];
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests;
|
||||
|
||||
public sealed class FunctionMetadataFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateEntityTrigger_SetsCorrectNameAndBindings()
|
||||
{
|
||||
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateEntityTrigger("myAgent");
|
||||
|
||||
Assert.Equal("dafx-myAgent", metadata.Name);
|
||||
Assert.Equal("dotnet-isolated", metadata.Language);
|
||||
Assert.Equal(BuiltInFunctions.RunAgentEntityFunctionEntryPoint, metadata.EntryPoint);
|
||||
Assert.NotNull(metadata.RawBindings);
|
||||
Assert.Equal(2, metadata.RawBindings.Count);
|
||||
Assert.Contains("entityTrigger", metadata.RawBindings[0]);
|
||||
Assert.Contains("durableClient", metadata.RawBindings[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHttpTrigger_SetsCorrectNameRouteAndDefaults()
|
||||
{
|
||||
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateHttpTrigger(
|
||||
"myWorkflow", "workflows/myWorkflow/run", BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint);
|
||||
|
||||
Assert.Equal("http-myWorkflow", metadata.Name);
|
||||
Assert.Equal("dotnet-isolated", metadata.Language);
|
||||
Assert.Equal(BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, metadata.EntryPoint);
|
||||
Assert.NotNull(metadata.RawBindings);
|
||||
Assert.Equal(3, metadata.RawBindings.Count);
|
||||
Assert.Contains("httpTrigger", metadata.RawBindings[0]);
|
||||
Assert.Contains("workflows/myWorkflow/run", metadata.RawBindings[0]);
|
||||
Assert.Contains("\"post\"", metadata.RawBindings[0]);
|
||||
Assert.Contains("http", metadata.RawBindings[1]);
|
||||
Assert.Contains("durableClient", metadata.RawBindings[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHttpTrigger_RespectsCustomMethods()
|
||||
{
|
||||
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateHttpTrigger(
|
||||
"status", "workflows/status/{runId}", BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, methods: "\"get\"");
|
||||
|
||||
Assert.NotNull(metadata.RawBindings);
|
||||
Assert.Contains("\"get\"", metadata.RawBindings[0]);
|
||||
Assert.DoesNotContain("\"post\"", metadata.RawBindings[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateActivityTrigger_SetsCorrectNameAndBindings()
|
||||
{
|
||||
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateActivityTrigger("dafx-MyExecutor");
|
||||
|
||||
Assert.Equal("dafx-MyExecutor", metadata.Name);
|
||||
Assert.Equal("dotnet-isolated", metadata.Language);
|
||||
Assert.Equal(BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, metadata.EntryPoint);
|
||||
Assert.NotNull(metadata.RawBindings);
|
||||
Assert.Equal(2, metadata.RawBindings.Count);
|
||||
Assert.Contains("activityTrigger", metadata.RawBindings[0]);
|
||||
Assert.Contains("durableClient", metadata.RawBindings[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateOrchestrationTrigger_SetsCorrectNameAndBindings()
|
||||
{
|
||||
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateOrchestrationTrigger(
|
||||
"dafx-MyWorkflow", BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint);
|
||||
|
||||
Assert.Equal("dafx-MyWorkflow", metadata.Name);
|
||||
Assert.Equal("dotnet-isolated", metadata.Language);
|
||||
Assert.Equal(BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, metadata.EntryPoint);
|
||||
Assert.NotNull(metadata.RawBindings);
|
||||
Assert.Single(metadata.RawBindings);
|
||||
Assert.Contains("orchestrationTrigger", metadata.RawBindings[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateWorkflowMcpToolTrigger_SetsCorrectNameAndBindings()
|
||||
{
|
||||
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateWorkflowMcpToolTrigger("Translate", "Translate text");
|
||||
|
||||
Assert.Equal("mcptool-Translate", metadata.Name);
|
||||
Assert.Equal("dotnet-isolated", metadata.Language);
|
||||
Assert.Equal(BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint, metadata.EntryPoint);
|
||||
Assert.NotNull(metadata.RawBindings);
|
||||
Assert.Equal(3, metadata.RawBindings.Count);
|
||||
|
||||
// Verify all bindings are valid JSON
|
||||
foreach (string binding in metadata.RawBindings)
|
||||
{
|
||||
JsonDocument.Parse(binding);
|
||||
}
|
||||
|
||||
// mcpToolTrigger binding
|
||||
Assert.Contains("mcpToolTrigger", metadata.RawBindings[0]);
|
||||
Assert.Contains("\"toolName\":\"Translate\"", metadata.RawBindings[0]);
|
||||
Assert.Contains("\"description\":\"Translate text\"", metadata.RawBindings[0]);
|
||||
Assert.Contains("toolProperties", metadata.RawBindings[0]);
|
||||
|
||||
// mcpToolProperty binding for input
|
||||
Assert.Contains("mcpToolProperty", metadata.RawBindings[1]);
|
||||
Assert.Contains("\"propertyName\":\"input\"", metadata.RawBindings[1]);
|
||||
Assert.Contains("\"isRequired\":true", metadata.RawBindings[1]);
|
||||
|
||||
// durableClient binding
|
||||
Assert.Contains("durableClient", metadata.RawBindings[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateWorkflowMcpToolTrigger_UsesDefaultDescription_WhenNull()
|
||||
{
|
||||
DefaultFunctionMetadata metadata = FunctionMetadataFactory.CreateWorkflowMcpToolTrigger("MyWorkflow", description: null);
|
||||
|
||||
Assert.NotNull(metadata.RawBindings);
|
||||
Assert.Contains("Run the MyWorkflow workflow", metadata.RawBindings[0]);
|
||||
}
|
||||
}
|
||||
+161
@@ -250,6 +250,129 @@ public class AIContextProviderChatClientTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shared Options Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync()
|
||||
{
|
||||
// Arrange: track tool count seen by the inner client on each call
|
||||
var toolCountsSeenByInner = new List<int>();
|
||||
|
||||
var innerClient = CreateMockChatClient(
|
||||
onGetResponse: (_, options, _) =>
|
||||
{
|
||||
toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0);
|
||||
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
|
||||
});
|
||||
|
||||
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
var sharedOptions = new ChatOptions
|
||||
{
|
||||
Tools = new List<AITool> { new TestAITool() }
|
||||
};
|
||||
|
||||
// Act: make 3 calls reusing the same ChatOptions
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await RunWithAgentContextAsync(chatClient, sharedOptions);
|
||||
}
|
||||
|
||||
// Assert: each call should see exactly 2 tools (1 baseline + 1 injected)
|
||||
Assert.Equal(3, toolCountsSeenByInner.Count);
|
||||
Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = CreateMockChatClient(
|
||||
onGetResponse: (_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")])));
|
||||
|
||||
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
var baselineTool = new TestAITool();
|
||||
var originalTools = new List<AITool> { baselineTool };
|
||||
var sharedOptions = new ChatOptions
|
||||
{
|
||||
Tools = originalTools
|
||||
};
|
||||
|
||||
// Act
|
||||
await RunWithAgentContextAsync(chatClient, sharedOptions);
|
||||
|
||||
// Assert: the original list should still contain only the baseline tool
|
||||
Assert.Single(originalTools);
|
||||
Assert.Same(baselineTool, originalTools[0]);
|
||||
Assert.Same(originalTools, sharedOptions.Tools);
|
||||
Assert.Same(baselineTool, originalTools[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var toolCountsSeenByInner = new List<int>();
|
||||
|
||||
var innerClient = CreateMockStreamingChatClient(
|
||||
onGetStreamingResponse: (_, options, _) =>
|
||||
{
|
||||
toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0);
|
||||
return ToAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "Response"));
|
||||
});
|
||||
|
||||
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
var sharedOptions = new ChatOptions
|
||||
{
|
||||
Tools = new List<AITool> { new TestAITool() }
|
||||
};
|
||||
|
||||
// Act: make 3 streaming calls reusing the same ChatOptions
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions);
|
||||
}
|
||||
|
||||
// Assert: each call should see exactly 2 tools (1 baseline + 1 injected)
|
||||
Assert.Equal(3, toolCountsSeenByInner.Count);
|
||||
Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = CreateMockStreamingChatClient(
|
||||
onGetStreamingResponse: (_, _, _) => ToAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "Response")));
|
||||
|
||||
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
var baselineTool = new TestAITool();
|
||||
var originalTools = new List<AITool> { baselineTool };
|
||||
var sharedOptions = new ChatOptions
|
||||
{
|
||||
Tools = originalTools
|
||||
};
|
||||
|
||||
// Act
|
||||
await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions);
|
||||
|
||||
// Assert: the original list should still contain only the baseline tool
|
||||
Assert.Single(originalTools);
|
||||
Assert.Same(baselineTool, originalTools[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Builder Extension Tests
|
||||
|
||||
[Fact]
|
||||
@@ -341,6 +464,44 @@ public class AIContextProviderChatClientTests
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a chat client within an agent context with the specified options.
|
||||
/// </summary>
|
||||
private static async Task RunWithAgentContextAsync(AIContextProviderChatClient chatClient, ChatOptions options)
|
||||
{
|
||||
var agent = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = async (messages, session, agentOptions, ct) =>
|
||||
{
|
||||
var response = await chatClient.GetResponseAsync(messages, options, ct);
|
||||
return new AgentResponse(response);
|
||||
}
|
||||
};
|
||||
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a streaming chat client within an agent context with the specified options.
|
||||
/// </summary>
|
||||
private static async Task RunStreamingWithAgentContextAsync(AIContextProviderChatClient chatClient, List<ChatResponseUpdate> updates, ChatOptions options)
|
||||
{
|
||||
var agent = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = async (messages, session, agentOptions, ct) =>
|
||||
{
|
||||
await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options, ct))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")]);
|
||||
}
|
||||
};
|
||||
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
|
||||
}
|
||||
|
||||
private static IChatClient CreateMockChatClient(
|
||||
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, Task<ChatResponse>> onGetResponse)
|
||||
{
|
||||
|
||||
+766
@@ -0,0 +1,766 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains unit tests for the <see cref="ChatHistoryPersistingChatClient"/> decorator,
|
||||
/// verifying that it persists messages via the <see cref="ChatHistoryProvider"/> after each
|
||||
/// individual service call by default, or marks messages for end-of-run persistence when the
|
||||
/// <see cref="ChatClientAgentOptions.PersistChatHistoryAtEndOfRun"/> option is enabled.
|
||||
/// </summary>
|
||||
public class ChatHistoryPersistingChatClientTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that by default (PersistChatHistoryAtEndOfRun is false),
|
||||
/// the ChatHistoryProvider receives messages after a successful non-streaming call.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_PersistsMessagesPerServiceCall_ByDefaultAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert — InvokedCoreAsync should be called by the decorator (per service call)
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
|
||||
x.RequestMessages.Any(m => m.Text == "test") &&
|
||||
x.ResponseMessages!.Any(m => m.Text == "response")),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is active (default),
|
||||
/// the ChatHistoryProvider receives messages at the end of the run.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_PersistsMessagesAtEndOfRun_WhenOptionEnabledAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
PersistChatHistoryAtEndOfRun = true,
|
||||
});
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert — InvokedCoreAsync should be called once by the agent (end of run)
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
|
||||
x.RequestMessages.Any(m => m.Text == "test") &&
|
||||
x.ResponseMessages!.Any(m => m.Text == "response")),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is active (default) and the service call fails,
|
||||
/// the ChatHistoryProvider is notified with the exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_NotifiesProviderOfFailure_WhenPerServiceCallPersistenceActiveAsync()
|
||||
{
|
||||
// Arrange
|
||||
var expectedException = new InvalidOperationException("Service failed");
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ThrowsAsync(expectedException);
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
|
||||
|
||||
// Assert — the decorator should have notified the provider of the failure
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
|
||||
x.InvokeException != null &&
|
||||
x.InvokeException.Message == "Service failed"),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the decorator is injected in persist mode by default
|
||||
/// and can be discovered via GetService.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ChatClient_ContainsDecorator_InPersistMode_ByDefault()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
|
||||
// Act
|
||||
ChatClientAgent agent = new(mockService.Object, options: new());
|
||||
|
||||
// Assert
|
||||
var decorator = agent.ChatClient.GetService<ChatHistoryPersistingChatClient>();
|
||||
Assert.NotNull(decorator);
|
||||
Assert.False(decorator.MarkOnly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the decorator is injected in mark-only mode when PersistChatHistoryAtEndOfRun is true.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ChatClient_ContainsDecorator_InMarkOnlyMode_WhenPersistAtEndOfRun()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
|
||||
// Act
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
PersistChatHistoryAtEndOfRun = true,
|
||||
});
|
||||
|
||||
// Assert
|
||||
var decorator = agent.ChatClient.GetService<ChatHistoryPersistingChatClient>();
|
||||
Assert.NotNull(decorator);
|
||||
Assert.True(decorator.MarkOnly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the decorator is NOT injected when UseProvidedChatClientAsIs is true.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ChatClient_DoesNotContainDecorator_WhenUseProvidedChatClientAsIs()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
|
||||
// Act
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
UseProvidedChatClientAsIs = true,
|
||||
});
|
||||
|
||||
// Assert
|
||||
var decorator = agent.ChatClient.GetService<ChatHistoryPersistingChatClient>();
|
||||
Assert.Null(decorator);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the PersistChatHistoryAtEndOfRun option is included in Clone().
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ChatClientAgentOptions_Clone_IncludesPersistChatHistoryAtEndOfRun()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
PersistChatHistoryAtEndOfRun = true,
|
||||
};
|
||||
|
||||
// Act
|
||||
var cloned = options.Clone();
|
||||
|
||||
// Assert
|
||||
Assert.True(cloned.PersistChatHistoryAtEndOfRun);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is active (default) and the service call
|
||||
/// involves a function invocation loop, the ChatHistoryProvider is called after each individual
|
||||
/// service call (not just once at the end).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_PersistsPerServiceCall_DuringFunctionInvocationLoopAsync()
|
||||
{
|
||||
// Arrange
|
||||
int serviceCallCount = 0;
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(() =>
|
||||
{
|
||||
serviceCallCount++;
|
||||
if (serviceCallCount == 1)
|
||||
{
|
||||
// First call returns a tool call
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>())])]));
|
||||
}
|
||||
|
||||
// Second call returns a final response
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")]));
|
||||
});
|
||||
|
||||
var invokedContexts = new List<ChatHistoryProvider.InvokedContext>();
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx))
|
||||
.Returns(() => new ValueTask());
|
||||
|
||||
// Define a simple tool
|
||||
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [tool] },
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
}, services: new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
Exception? caughtException = null;
|
||||
try
|
||||
{
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
caughtException = ex;
|
||||
}
|
||||
|
||||
// Diagnostic: check if there was an unexpected exception
|
||||
Assert.Null(caughtException);
|
||||
|
||||
// Assert — the decorator should have been called twice (once per service call in the function invocation loop)
|
||||
Assert.Equal(2, serviceCallCount);
|
||||
Assert.Equal(2, invokedContexts.Count);
|
||||
|
||||
// First invocation should have the user message as request and tool call response
|
||||
Assert.NotNull(invokedContexts[0].ResponseMessages);
|
||||
var firstRequestMessages = invokedContexts[0].RequestMessages.ToList();
|
||||
Assert.Contains(firstRequestMessages, m => m.Text == "test");
|
||||
Assert.Contains(invokedContexts[0].ResponseMessages!, m => m.Contents.OfType<FunctionCallContent>().Any());
|
||||
|
||||
// Second invocation: request messages should NOT include the original user message (already notified).
|
||||
// It should only include messages added since the first call (assistant tool call + tool result).
|
||||
Assert.NotNull(invokedContexts[1].ResponseMessages);
|
||||
var secondRequestMessages = invokedContexts[1].RequestMessages.ToList();
|
||||
Assert.DoesNotContain(secondRequestMessages, m => m.Text == "test");
|
||||
Assert.Contains(invokedContexts[1].ResponseMessages!, m => m.Text == "final response");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is active (default) with streaming,
|
||||
/// the ChatHistoryProvider receives messages after the stream completes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_PersistsMessagesPerServiceCall_ByDefaultAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(CreateAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "streaming "),
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "response")));
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")], session))
|
||||
{
|
||||
// Consume stream
|
||||
}
|
||||
|
||||
// Assert — InvokedCoreAsync should be called by the decorator
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
|
||||
x.RequestMessages.Any(m => m.Text == "test") &&
|
||||
x.ResponseMessages != null),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is active (default),
|
||||
/// AIContextProviders are also notified of new messages after a successful call.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_NotifiesAIContextProviders_ByDefaultAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
|
||||
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]);
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => new ValueTask<AIContext>(new AIContext()));
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
AIContextProviders = [mockContextProvider.Object],
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert — InvokedCoreAsync should be called by the decorator for the AIContextProvider
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<AIContextProvider.InvokedContext>(x =>
|
||||
x.ResponseMessages != null &&
|
||||
x.ResponseMessages.Any(m => m.Text == "response")),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is active (default) and the service fails,
|
||||
/// AIContextProviders are notified of the failure.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_NotifiesAIContextProvidersOfFailure_ByDefaultAsync()
|
||||
{
|
||||
// Arrange
|
||||
var expectedException = new InvalidOperationException("Service failed");
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ThrowsAsync(expectedException);
|
||||
|
||||
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
|
||||
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]);
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => new ValueTask<AIContext>(new AIContext()));
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
AIContextProviders = [mockContextProvider.Object],
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
|
||||
|
||||
// Assert — the decorator should have notified the AIContextProvider of the failure
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<AIContextProvider.InvokedContext>(x =>
|
||||
x.InvokeException != null &&
|
||||
x.InvokeException.Message == "Service failed"),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is active (default),
|
||||
/// both ChatHistoryProvider and AIContextProviders are notified together.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_NotifiesBothProviders_ByDefaultAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => new ValueTask());
|
||||
|
||||
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
|
||||
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestAIContextProvider"]);
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => new ValueTask<AIContext>(new AIContext()));
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
AIContextProviders = [mockContextProvider.Object],
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert — both providers should have been notified
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
|
||||
x.ResponseMessages != null &&
|
||||
x.ResponseMessages.Any(m => m.Text == "response")),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
|
||||
mockContextProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<AIContextProvider.InvokedContext>(x =>
|
||||
x.ResponseMessages != null &&
|
||||
x.ResponseMessages.Any(m => m.Text == "response")),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that during a FIC loop, response messages from the first call are not
|
||||
/// re-notified as request messages on the second call.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DoesNotReNotifyResponseMessagesAsRequestMessages_DuringFicLoopAsync()
|
||||
{
|
||||
// Arrange
|
||||
int serviceCallCount = 0;
|
||||
var assistantToolCallMessage = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>())]);
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(() =>
|
||||
{
|
||||
serviceCallCount++;
|
||||
if (serviceCallCount == 1)
|
||||
{
|
||||
return Task.FromResult(new ChatResponse([assistantToolCallMessage]));
|
||||
}
|
||||
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")]));
|
||||
});
|
||||
|
||||
var invokedContexts = new List<ChatHistoryProvider.InvokedContext>();
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx))
|
||||
.Returns(() => new ValueTask());
|
||||
|
||||
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [tool] },
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
}, services: new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, invokedContexts.Count);
|
||||
|
||||
// The assistant tool call message was a response in call 1
|
||||
Assert.Contains(invokedContexts[0].ResponseMessages!, m => ReferenceEquals(m, assistantToolCallMessage));
|
||||
|
||||
// It should NOT appear as a request in call 2 (it was already notified as a response)
|
||||
var secondRequestMessages = invokedContexts[1].RequestMessages.ToList();
|
||||
Assert.DoesNotContain(secondRequestMessages, m => ReferenceEquals(m, assistantToolCallMessage));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when a failure occurs on the second call in a FIC loop,
|
||||
/// only new request messages (not previously notified) are sent in the failure notification.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DeduplicatesRequestMessages_OnFailureDuringFicLoopAsync()
|
||||
{
|
||||
// Arrange
|
||||
int serviceCallCount = 0;
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(() =>
|
||||
{
|
||||
serviceCallCount++;
|
||||
if (serviceCallCount == 1)
|
||||
{
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, [new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>())])]));
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Service failure on second call");
|
||||
});
|
||||
|
||||
var invokedContexts = new List<ChatHistoryProvider.InvokedContext>();
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Callback((ChatHistoryProvider.InvokedContext ctx, CancellationToken _) => invokedContexts.Add(ctx))
|
||||
.Returns(() => new ValueTask());
|
||||
|
||||
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [tool] },
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
}, services: new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
agent.RunAsync([new(ChatRole.User, "test")], session));
|
||||
|
||||
// Assert — should have 2 notifications: success on call 1, failure on call 2
|
||||
Assert.Equal(2, invokedContexts.Count);
|
||||
|
||||
// First notification: success, has user message as request
|
||||
Assert.Null(invokedContexts[0].InvokeException);
|
||||
Assert.Contains(invokedContexts[0].RequestMessages, m => m.Text == "test");
|
||||
|
||||
// Second notification: failure, should NOT include the user message (already notified)
|
||||
Assert.NotNull(invokedContexts[1].InvokeException);
|
||||
var failureRequestMessages = invokedContexts[1].RequestMessages.ToList();
|
||||
Assert.DoesNotContain(failureRequestMessages, m => m.Text == "test");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that after a successful run with per-service-call persistence, the notified
|
||||
/// messages are stamped with the persisted marker so they are not re-notified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_MarksNotifiedMessages_WithPersistedMarkerAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() => new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
var inputMessage = new ChatMessage(ChatRole.User, "test");
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([inputMessage], session);
|
||||
|
||||
// Assert — input message should be marked as persisted
|
||||
Assert.True(
|
||||
inputMessage.AdditionalProperties?.ContainsKey(ChatHistoryPersistingChatClient.PersistedMarkerKey) == true,
|
||||
"Input message should be marked as persisted after a successful run.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is enabled and the inner client returns a
|
||||
/// conversation ID, the session's ConversationId is updated after the service call.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_UpdatesSessionConversationId_WhenPerServiceCallPersistenceEnabledAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "conv-123";
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])
|
||||
{
|
||||
ConversationId = ExpectedConversationId,
|
||||
});
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert — session should have the conversation ID returned by the inner client
|
||||
Assert.Equal(ExpectedConversationId, session!.ConversationId);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<ChatResponseUpdate> CreateAsyncEnumerableAsync(params ChatResponseUpdate[] updates)
|
||||
{
|
||||
foreach (var update in updates)
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
+159
-35
@@ -9,49 +9,173 @@ using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
internal sealed class TempDirectory : IDisposable
|
||||
{
|
||||
public DirectoryInfo DirectoryInfo { get; }
|
||||
|
||||
public TempDirectory()
|
||||
{
|
||||
string tempDirPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
this.DirectoryInfo = Directory.CreateDirectory(tempDirPath);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this.DisposeInternal();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void DisposeInternal()
|
||||
{
|
||||
if (this.DirectoryInfo.Exists)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Best efforts
|
||||
this.DirectoryInfo.Delete(recursive: true);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
~TempDirectory()
|
||||
{
|
||||
// Best efforts
|
||||
this.DisposeInternal();
|
||||
}
|
||||
|
||||
public static implicit operator DirectoryInfo(TempDirectory tempDirectory) => tempDirectory.DirectoryInfo;
|
||||
|
||||
public string FullName => this.DirectoryInfo.FullName;
|
||||
|
||||
public bool IsParentOf(FileInfo candidate)
|
||||
{
|
||||
if (candidate.Directory is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.Directory.FullName == this.DirectoryInfo.FullName)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.IsParentOf(candidate.Directory);
|
||||
}
|
||||
|
||||
public bool IsParentOf(DirectoryInfo candidate)
|
||||
{
|
||||
while (candidate.Parent is not null)
|
||||
{
|
||||
if (candidate.Parent.FullName == this.DirectoryInfo.FullName)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
candidate = candidate.Parent;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public sealed class FileSystemJsonCheckpointStoreTests
|
||||
{
|
||||
public static JsonElement TestData => JsonSerializer.SerializeToElement(new { test = "data" });
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCheckpointAsync_ShouldPersistIndexToDiskBeforeDisposeAsync()
|
||||
{
|
||||
// Arrange
|
||||
DirectoryInfo tempDir = new(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()));
|
||||
FileSystemJsonCheckpointStore? store = null;
|
||||
using TempDirectory tempDirectory = new();
|
||||
using FileSystemJsonCheckpointStore? store = new(tempDirectory);
|
||||
|
||||
try
|
||||
string runId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Act
|
||||
CheckpointInfo checkpoint = await store.CreateCheckpointAsync(runId, TestData);
|
||||
|
||||
// Assert - Check the file size before disposing to verify data was flushed to disk
|
||||
// The index.jsonl file is held exclusively by the store, so we check via FileInfo
|
||||
string indexPath = Path.Combine(tempDirectory.FullName, "index.jsonl");
|
||||
FileInfo indexFile = new(indexPath);
|
||||
indexFile.Refresh();
|
||||
long fileSizeBeforeDispose = indexFile.Length;
|
||||
|
||||
// Data should already be on disk (file size > 0) before we dispose
|
||||
fileSizeBeforeDispose.Should().BeGreaterThan(0, "index.jsonl should be flushed to disk after CreateCheckpointAsync");
|
||||
|
||||
// Dispose to release file lock before final verification
|
||||
store.Dispose();
|
||||
|
||||
string[] lines = File.ReadAllLines(indexPath);
|
||||
lines.Should().HaveCount(1);
|
||||
lines[0].Should().Contain(checkpoint.CheckpointId);
|
||||
}
|
||||
|
||||
private async ValueTask Run_EscapeRootFolderTestAsync(string escapingPath)
|
||||
{
|
||||
// Arrange
|
||||
using TempDirectory tempDirectory = new();
|
||||
using FileSystemJsonCheckpointStore store = new(tempDirectory);
|
||||
|
||||
string naivePath = Path.Combine(tempDirectory.DirectoryInfo.FullName, escapingPath);
|
||||
|
||||
// Check that the naive path is actually outside the temp directory to validate the test is meaningful
|
||||
FileInfo naiveCheckpointFile = new(naivePath);
|
||||
tempDirectory.IsParentOf(naiveCheckpointFile).Should().BeFalse("The naive path should be outside the root folder to validate that escaping is necessary.");
|
||||
|
||||
// Act
|
||||
CheckpointInfo checkpointInfo = await store.CreateCheckpointAsync(escapingPath, TestData);
|
||||
|
||||
// Assert
|
||||
string naivePathWithCheckpointId = Path.Combine(tempDirectory.DirectoryInfo.FullName, $"{escapingPath}_{checkpointInfo.CheckpointId}.json");
|
||||
new FileInfo(naivePathWithCheckpointId).Exists.Should().BeFalse("The naive path should not be used to save a checkpoint file.");
|
||||
|
||||
string actualFileName = store.GetFileNameForCheckpoint(escapingPath, checkpointInfo);
|
||||
string actualFilePath = Path.Combine(tempDirectory.DirectoryInfo.FullName, actualFileName);
|
||||
FileInfo actualFile = new(actualFilePath);
|
||||
|
||||
tempDirectory.IsParentOf(actualFile).Should().BeTrue("The actual checkpoint should be saved inside the root folder.");
|
||||
actualFile.Exists.Should().BeTrue("The actual path should be used to save a checkpoint file.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCheckpointAsync_ShouldNotEscapeRootFolderAsync()
|
||||
{
|
||||
// The SessionId is used as part of the file name, but if it contains path characters such as /.. it can escape the root folder.
|
||||
// Testing that such characters are escaped properly to prevent directory traversal attacks, etc.
|
||||
|
||||
await this.Run_EscapeRootFolderTestAsync("../valid_suffix");
|
||||
|
||||
#if !NETFRAMEWORK
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
store = new(tempDir);
|
||||
string runId = Guid.NewGuid().ToString("N");
|
||||
JsonElement testData = JsonSerializer.SerializeToElement(new { test = "data" });
|
||||
|
||||
// Act
|
||||
CheckpointInfo checkpoint = await store.CreateCheckpointAsync(runId, testData);
|
||||
|
||||
// Assert - Check the file size before disposing to verify data was flushed to disk
|
||||
// The index.jsonl file is held exclusively by the store, so we check via FileInfo
|
||||
string indexPath = Path.Combine(tempDir.FullName, "index.jsonl");
|
||||
FileInfo indexFile = new(indexPath);
|
||||
indexFile.Refresh();
|
||||
long fileSizeBeforeDispose = indexFile.Length;
|
||||
|
||||
// Data should already be on disk (file size > 0) before we dispose
|
||||
fileSizeBeforeDispose.Should().BeGreaterThan(0, "index.jsonl should be flushed to disk after CreateCheckpointAsync");
|
||||
|
||||
// Dispose to release file lock before final verification
|
||||
store.Dispose();
|
||||
store = null;
|
||||
|
||||
string[] lines = File.ReadAllLines(indexPath);
|
||||
lines.Should().HaveCount(1);
|
||||
lines[0].Should().Contain(checkpoint.CheckpointId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
store?.Dispose();
|
||||
if (tempDir.Exists)
|
||||
{
|
||||
tempDir.Delete(recursive: true);
|
||||
}
|
||||
// Windows allows both \ and / as path separators, so we test both
|
||||
await this.Run_EscapeRootFolderTestAsync("..\\valid_suffix");
|
||||
}
|
||||
#else
|
||||
// .NET Framework is always on Windows
|
||||
await this.Run_EscapeRootFolderTestAsync("..\\valid_suffix");
|
||||
#endif
|
||||
}
|
||||
|
||||
private const string InvalidPathCharsWin32 = "\\/:*?\"<>|";
|
||||
private const string InvalidPathCharsUnix = "/";
|
||||
private const string InvalidPathCharsMacOS = "/:";
|
||||
|
||||
[Theory]
|
||||
[InlineData(InvalidPathCharsWin32)]
|
||||
[InlineData(InvalidPathCharsUnix)]
|
||||
[InlineData(InvalidPathCharsMacOS)]
|
||||
public async Task CreateCheckpointAsync_EscapesInvalidCharsAsync(string invalidChars)
|
||||
{
|
||||
// Arrange
|
||||
using TempDirectory tempDirectory = new();
|
||||
using FileSystemJsonCheckpointStore store = new(tempDirectory);
|
||||
|
||||
string runId = $"prefix_{invalidChars}_suffix";
|
||||
|
||||
Func<Task> createCheckpointAction = async () => await store.CreateCheckpointAsync(runId, TestData);
|
||||
await createCheckpointAction.Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,6 +673,55 @@ public class JsonSerializationTests
|
||||
ValidateCheckpoint(retrievedCheckpoint, prototype);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_SessionState_JsonRoundtrip_WithPendingRequests()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, ExternalRequest> pendingRequests = new()
|
||||
{
|
||||
["call-1"] = TestExternalRequest,
|
||||
["call-2"] = ExternalRequest.Create(TestPort, "Request2", "OtherData"),
|
||||
};
|
||||
|
||||
WorkflowSession.SessionState prototype = new(
|
||||
sessionId: "test-session-123",
|
||||
lastCheckpoint: TestParentCheckpointInfo,
|
||||
pendingRequests: pendingRequests);
|
||||
|
||||
// Act
|
||||
WorkflowSession.SessionState result = RunJsonRoundtrip(prototype);
|
||||
|
||||
// Assert
|
||||
result.SessionId.Should().Be(prototype.SessionId);
|
||||
result.LastCheckpoint.Should().Be(prototype.LastCheckpoint);
|
||||
result.StateBag.Should().NotBeNull();
|
||||
result.PendingRequests.Should().NotBeNull()
|
||||
.And.HaveCount(pendingRequests.Count);
|
||||
|
||||
foreach (string key in pendingRequests.Keys)
|
||||
{
|
||||
result.PendingRequests.Should().ContainKey(key);
|
||||
ValidateExternalRequest(result.PendingRequests![key], pendingRequests[key]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_SessionState_JsonRoundtrip_WithoutPendingRequests()
|
||||
{
|
||||
// Arrange
|
||||
WorkflowSession.SessionState prototype = new(
|
||||
sessionId: "test-session-456",
|
||||
lastCheckpoint: null);
|
||||
|
||||
// Act
|
||||
WorkflowSession.SessionState result = RunJsonRoundtrip(prototype);
|
||||
|
||||
// Assert
|
||||
result.SessionId.Should().Be(prototype.SessionId);
|
||||
result.LastCheckpoint.Should().BeNull();
|
||||
result.PendingRequests.Should().BeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the default behavior (without AllowOutOfOrderMetadataProperties) fails
|
||||
/// when $type metadata is not the first property, demonstrating the PostgreSQL jsonb issue.
|
||||
|
||||
@@ -28,6 +28,184 @@ public sealed class ExpectedException : Exception
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple agent that emits a FunctionCallContent or ToolApprovalRequestContent request.
|
||||
/// Used to test that RequestInfoEvent handling preserves the original content type.
|
||||
/// </summary>
|
||||
internal sealed class RequestEmittingAgent : AIAgent
|
||||
{
|
||||
private readonly AIContent _requestContent;
|
||||
private readonly bool _completeOnResponse;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="RequestEmittingAgent"/> that emits the given request content.
|
||||
/// </summary>
|
||||
/// <param name="requestContent">The content to emit on each turn.</param>
|
||||
/// <param name="completeOnResponse">
|
||||
/// When <see langword="true"/>, the agent emits a text completion instead of re-emitting
|
||||
/// the request when the incoming messages contain a <see cref="FunctionResultContent"/>
|
||||
/// or <see cref="ToolApprovalResponseContent"/>. This models realistic agent behaviour
|
||||
/// where the agent processes the tool result and produces a final answer.
|
||||
/// </param>
|
||||
public RequestEmittingAgent(AIContent requestContent, bool completeOnResponse = false)
|
||||
{
|
||||
this._requestContent = requestContent;
|
||||
this._completeOnResponse = completeOnResponse;
|
||||
}
|
||||
|
||||
private sealed class Session : AgentSession
|
||||
{
|
||||
public Session() { }
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new Session());
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new Session());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> default;
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> this.RunStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this._completeOnResponse && messages.Any(m => m.Contents.Any(c =>
|
||||
c is FunctionResultContent || c is ToolApprovalResponseContent)))
|
||||
{
|
||||
yield return new AgentResponseUpdate(ChatRole.Assistant, [new TextContent("Request processed")]);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Emit the request content
|
||||
yield return new AgentResponseUpdate(ChatRole.Assistant, [this._requestContent]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class KickoffOnStartExecutor : ChatProtocolExecutor
|
||||
{
|
||||
private static readonly ChatProtocolExecutorOptions s_options = new()
|
||||
{
|
||||
AutoSendTurnToken = false,
|
||||
};
|
||||
|
||||
private readonly string _downstreamExecutorId;
|
||||
private readonly string _kickoffInputText;
|
||||
private readonly string _kickoffMessageText;
|
||||
private readonly string _regularResumeText;
|
||||
private readonly string _regularProcessedText;
|
||||
|
||||
public KickoffOnStartExecutor(
|
||||
string id,
|
||||
string downstreamExecutorId,
|
||||
string kickoffInputText,
|
||||
string kickoffMessageText,
|
||||
string regularResumeText,
|
||||
string regularProcessedText)
|
||||
: base(id, s_options)
|
||||
{
|
||||
this._downstreamExecutorId = downstreamExecutorId;
|
||||
this._kickoffInputText = kickoffInputText;
|
||||
this._kickoffMessageText = kickoffMessageText;
|
||||
this._regularResumeText = regularResumeText;
|
||||
this._regularProcessedText = regularProcessedText;
|
||||
}
|
||||
|
||||
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<string> textContents =
|
||||
[
|
||||
.. messages
|
||||
.SelectMany(message => message.Contents.OfType<TextContent>())
|
||||
.Select(content => content.Text)
|
||||
];
|
||||
|
||||
if (textContents.Contains(this._kickoffInputText, StringComparer.Ordinal))
|
||||
{
|
||||
await context.SendMessageAsync(
|
||||
new List<ChatMessage> { new(ChatRole.User, this._kickoffMessageText) },
|
||||
this._downstreamExecutorId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(
|
||||
new TurnToken(emitEvents),
|
||||
this._downstreamExecutorId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (textContents.Contains(this._regularResumeText, StringComparer.Ordinal))
|
||||
{
|
||||
AgentResponseUpdate update = new(ChatRole.Assistant, [new TextContent(this._regularProcessedText)])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
ResponseId = Guid.NewGuid().ToString("N"),
|
||||
Role = ChatRole.Assistant,
|
||||
};
|
||||
|
||||
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A start executor that always emits a response update on every turn,
|
||||
/// useful for verifying that a TurnToken was delivered by the session.
|
||||
/// On the first turn (user messages present), it kicks off a downstream executor.
|
||||
/// </summary>
|
||||
internal sealed class TurnTrackingStartExecutor : ChatProtocolExecutor
|
||||
{
|
||||
private static readonly ChatProtocolExecutorOptions s_options = new()
|
||||
{
|
||||
AutoSendTurnToken = false,
|
||||
};
|
||||
|
||||
private readonly string _downstreamExecutorId;
|
||||
private readonly string _activatedMarker;
|
||||
private int _activationCount;
|
||||
|
||||
/// <summary>Gets the number of times this executor has been activated (i.e., <see cref="TakeTurnAsync"/> called).</summary>
|
||||
public int ActivationCount => this._activationCount;
|
||||
|
||||
public TurnTrackingStartExecutor(string id, string downstreamExecutorId, string activatedMarker)
|
||||
: base(id, s_options)
|
||||
{
|
||||
this._downstreamExecutorId = downstreamExecutorId;
|
||||
this._activatedMarker = activatedMarker;
|
||||
}
|
||||
|
||||
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref this._activationCount);
|
||||
|
||||
// On the first turn, forward user messages and a TurnToken to the downstream executor.
|
||||
if (messages.Any(m => m.Role == ChatRole.User))
|
||||
{
|
||||
await context.SendMessageAsync(
|
||||
messages,
|
||||
this._downstreamExecutorId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(
|
||||
new TurnToken(emitEvents),
|
||||
this._downstreamExecutorId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Always emit a marker to prove this executor was activated.
|
||||
AgentResponseUpdate update = new(ChatRole.Assistant, [new TextContent(this._activatedMarker)])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
ResponseId = Guid.NewGuid().ToString("N"),
|
||||
Role = ChatRole.Assistant,
|
||||
};
|
||||
|
||||
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkflowHostSmokeTests
|
||||
{
|
||||
private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent
|
||||
@@ -112,4 +290,445 @@ public class WorkflowHostSmokeTests
|
||||
|
||||
hadErrorContent.Should().BeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when a workflow emits a RequestInfoEvent with FunctionCallContent data,
|
||||
/// the AgentResponseUpdate preserves the original FunctionCallContent type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_FunctionCallContentPreservedInRequestInfoAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string CallId = "test-call-id";
|
||||
const string FunctionName = "testFunction";
|
||||
FunctionCallContent originalContent = new(CallId, FunctionName);
|
||||
RequestEmittingAgent requestAgent = new(originalContent);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
|
||||
// Act
|
||||
List<AgentResponseUpdate> updates = await workflow.AsAIAgent("WorkflowAgent")
|
||||
.RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello"))
|
||||
.ToListAsync();
|
||||
|
||||
// Assert
|
||||
AgentResponseUpdate? updateWithFunctionCall = updates.FirstOrDefault(u =>
|
||||
u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is FunctionCallContent));
|
||||
|
||||
updateWithFunctionCall.Should().NotBeNull("a FunctionCallContent should be present in the response updates");
|
||||
FunctionCallContent retrievedContent = updateWithFunctionCall!.Contents
|
||||
.OfType<FunctionCallContent>()
|
||||
.Should().ContainSingle()
|
||||
.Which;
|
||||
|
||||
retrievedContent.CallId.Should().NotBe(CallId);
|
||||
retrievedContent.CallId.Should().EndWith($":{CallId}");
|
||||
retrievedContent.Name.Should().Be(FunctionName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when a workflow emits a RequestInfoEvent with ToolApprovalRequestContent data,
|
||||
/// the AgentResponseUpdate preserves the original ToolApprovalRequestContent type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_ToolApprovalRequestContentPreservedInRequestInfoAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string RequestId = "test-request-id";
|
||||
McpServerToolCallContent mcpCall = new("call-id", "testToolName", "http://localhost");
|
||||
ToolApprovalRequestContent originalContent = new(RequestId, mcpCall);
|
||||
RequestEmittingAgent requestAgent = new(originalContent);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUserInputRequests = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
|
||||
// Act
|
||||
List<AgentResponseUpdate> updates = await workflow.AsAIAgent("WorkflowAgent")
|
||||
.RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello"))
|
||||
.ToListAsync();
|
||||
|
||||
// Assert
|
||||
AgentResponseUpdate? updateWithUserInput = updates.FirstOrDefault(u =>
|
||||
u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is ToolApprovalRequestContent));
|
||||
|
||||
updateWithUserInput.Should().NotBeNull("a ToolApprovalRequestContent should be present in the response updates");
|
||||
ToolApprovalRequestContent retrievedContent = updateWithUserInput!.Contents
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.Should().ContainSingle()
|
||||
.Which;
|
||||
|
||||
retrievedContent.Should().NotBeNull();
|
||||
retrievedContent.RequestId.Should().NotBe(RequestId);
|
||||
retrievedContent.RequestId.Should().EndWith($":{RequestId}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the full roundtrip: workflow emits a request, external caller responds, workflow processes response.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_FunctionCallRoundtrip_ResponseIsProcessedAsync()
|
||||
{
|
||||
// Arrange: Create an agent that emits a FunctionCallContent request
|
||||
const string CallId = "roundtrip-call-id";
|
||||
const string FunctionName = "testFunction";
|
||||
FunctionCallContent requestContent = new(CallId, FunctionName);
|
||||
RequestEmittingAgent requestAgent = new(requestContent, completeOnResponse: true);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
// Act 1: First call - should receive the FunctionCallContent request
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.User, "Start"),
|
||||
session).ToListAsync();
|
||||
|
||||
// Assert 1: We should have received a FunctionCallContent
|
||||
AgentResponseUpdate? updateWithRequest = firstCallUpdates.FirstOrDefault(u =>
|
||||
u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is FunctionCallContent));
|
||||
updateWithRequest.Should().NotBeNull("a FunctionCallContent should be present in the response updates");
|
||||
|
||||
FunctionCallContent receivedRequest = updateWithRequest!.Contents
|
||||
.OfType<FunctionCallContent>()
|
||||
.First();
|
||||
receivedRequest.CallId.Should().EndWith($":{CallId}");
|
||||
|
||||
// Act 2: Send the response back
|
||||
FunctionResultContent responseContent = new(receivedRequest.CallId, "test result");
|
||||
ChatMessage responseMessage = new(ChatRole.Tool, [responseContent]);
|
||||
|
||||
// Act 2: Run the workflow with the response and capture the resulting updates
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(responseMessage, session).ToListAsync();
|
||||
|
||||
// Assert 2: The response should be processed and the original request should no longer be pending.
|
||||
// Concretely, the workflow should not re-emit a FunctionCallContent with the same CallId.
|
||||
secondCallUpdates.Should().NotBeNull("processing the response should produce updates");
|
||||
secondCallUpdates.Should().NotBeEmpty("processing the response should progress the workflow");
|
||||
secondCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Should()
|
||||
.NotContain(c => c.CallId == receivedRequest.CallId, "the external FunctionCallContent request should be cleared after processing the response");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the full roundtrip for ToolApprovalRequestContent: workflow emits request, external caller responds.
|
||||
/// Verifying inbound ToolApprovalResponseContent conversion.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_ToolApprovalRoundtrip_ResponseIsProcessedAsync()
|
||||
{
|
||||
// Arrange: Create an agent that emits a ToolApprovalRequestContent request
|
||||
const string RequestId = "roundtrip-request-id";
|
||||
McpServerToolCallContent mcpCall = new("mcp-call-id", "testMcpTool", "http://localhost");
|
||||
ToolApprovalRequestContent requestContent = new(RequestId, mcpCall);
|
||||
RequestEmittingAgent requestAgent = new(requestContent, completeOnResponse: true);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUserInputRequests = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
// Act 1: First call - should receive the ToolApprovalRequestContent request
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.User, "Start"),
|
||||
session).ToListAsync();
|
||||
|
||||
// Assert 1: We should have received a ToolApprovalRequestContent
|
||||
AgentResponseUpdate? updateWithRequest = firstCallUpdates.FirstOrDefault(u =>
|
||||
u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is ToolApprovalRequestContent));
|
||||
updateWithRequest.Should().NotBeNull("a ToolApprovalRequestContent should be present in the response updates");
|
||||
|
||||
ToolApprovalRequestContent receivedRequest = updateWithRequest!.Contents
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.First();
|
||||
receivedRequest.RequestId.Should().EndWith($":{RequestId}");
|
||||
|
||||
// Act 2: Send the response back - use CreateResponse to get the right response type
|
||||
ToolApprovalResponseContent responseContent = receivedRequest.CreateResponse(approved: true);
|
||||
ChatMessage responseMessage = new(ChatRole.User, [responseContent]);
|
||||
|
||||
// Act 2: Run the workflow again with the response and capture the updates
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(responseMessage, session).ToListAsync();
|
||||
|
||||
// Assert 2: The response should be applied so that the original request is no longer pending
|
||||
secondCallUpdates.Should().NotBeEmpty("handling the user input response should produce follow-up updates");
|
||||
bool requestStillPresent = secondCallUpdates.Any(u =>
|
||||
u.RawRepresentation is RequestInfoEvent
|
||||
&& u.Contents.OfType<ToolApprovalRequestContent>().Any(r => r.RequestId == receivedRequest.RequestId));
|
||||
requestStillPresent.Should().BeFalse("the original ToolApprovalRequestContent should not be re-emitted after its response is processed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the mixed-message scenario: resume contains both an external response
|
||||
/// (FunctionResultContent matching a pending request) and regular non-response content
|
||||
/// in the same message.
|
||||
/// Verifies that regular content is still processed and that no duplicate
|
||||
/// pending-request errors, redundant FunctionCallContent re-emissions,
|
||||
/// or workflow errors occur.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_MixedResponseAndRegularMessage_BothProcessedAsync()
|
||||
{
|
||||
// Arrange: Create an agent that emits a FunctionCallContent request
|
||||
const string CallId = "mixed-call-id";
|
||||
const string FunctionName = "mixedTestFunction";
|
||||
FunctionCallContent requestContent = new(CallId, FunctionName);
|
||||
RequestEmittingAgent requestAgent = new(requestContent, completeOnResponse: true);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
// Act 1: First call - should receive the FunctionCallContent request
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.User, "Start"),
|
||||
session).ToListAsync();
|
||||
|
||||
// Assert 1: We should have received a FunctionCallContent
|
||||
AgentResponseUpdate requestUpdate = firstCallUpdates.First(u =>
|
||||
u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is FunctionCallContent));
|
||||
FunctionCallContent emittedRequest = requestUpdate.Contents.OfType<FunctionCallContent>().Single();
|
||||
|
||||
firstCallUpdates.Should().Contain(u => u.Contents.Any(c => c is FunctionCallContent),
|
||||
"the first call should emit a FunctionCallContent request");
|
||||
|
||||
// Act 2: Send a mixed message containing both the function result AND regular non-response content
|
||||
FunctionResultContent responseContent = new(emittedRequest.CallId, "tool output");
|
||||
ChatMessage mixedMessage = new(ChatRole.Tool, [responseContent, new TextContent("additional context")]);
|
||||
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(mixedMessage, session).ToListAsync();
|
||||
|
||||
// Assert 2: The workflow should have processed both parts without errors
|
||||
secondCallUpdates.Should().NotBeEmpty("the mixed message should produce follow-up updates");
|
||||
secondCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Should()
|
||||
.NotContain(c => c.CallId == emittedRequest.CallId, "the external FunctionCallContent should be cleared after the response is processed");
|
||||
secondCallUpdates
|
||||
.SelectMany(u => u.Contents.OfType<ErrorContent>())
|
||||
.Should()
|
||||
.BeEmpty("no workflow errors should occur when processing a mixed response-and-regular message");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_ResponseThenRegularAcrossMessages_NoDuplicateFunctionCallAsync()
|
||||
{
|
||||
const string CallId = "mixed-separate-call-id";
|
||||
const string FunctionName = "mixedSeparateTestFunction";
|
||||
|
||||
RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: true);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "Start"), session).ToListAsync();
|
||||
FunctionCallContent emittedRequest = firstCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Single();
|
||||
|
||||
ChatMessage[] resumeMessages =
|
||||
[
|
||||
new(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]),
|
||||
new(ChatRole.Tool, [new TextContent("extra context in separate message")])
|
||||
];
|
||||
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(resumeMessages, session).ToListAsync();
|
||||
|
||||
secondCallUpdates.Should().NotBeEmpty();
|
||||
secondCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Should()
|
||||
.NotContain(c => c.CallId == emittedRequest.CallId, "response+regular content split across messages should not re-emit the handled external request");
|
||||
secondCallUpdates
|
||||
.SelectMany(u => u.Contents.OfType<ErrorContent>())
|
||||
.Should()
|
||||
.BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_MatchingResponse_DoesNotCauseExtraTurnAsync()
|
||||
{
|
||||
const string CallId = "matching-response-call-id";
|
||||
const string FunctionName = "matchingResponseFunction";
|
||||
|
||||
RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: false);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "Start"), session).ToListAsync();
|
||||
FunctionCallContent emittedRequest = firstCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Single();
|
||||
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]),
|
||||
session).ToListAsync();
|
||||
|
||||
int functionCallCount = secondCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Count(c => c.CallId == emittedRequest.CallId);
|
||||
|
||||
functionCallCount.Should().Be(1, "a matching external response should not trigger an extra TurnToken-driven turn");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_MixedResponseAndRegularMessage_CrossExecutorStartExecutorIsReawakenedAsync()
|
||||
{
|
||||
const string StartExecutorId = "start-executor";
|
||||
const string KickoffInputText = "Start";
|
||||
const string KickoffMessageText = "kickoff downstream";
|
||||
const string ResumeRegularText = "resume regular";
|
||||
const string ResumeProcessedText = "regular message processed";
|
||||
const string CallId = "cross-executor-call-id";
|
||||
const string FunctionName = "crossExecutorFunction";
|
||||
|
||||
RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: true);
|
||||
ExecutorBinding requestBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
|
||||
KickoffOnStartExecutor startExecutor = new(
|
||||
StartExecutorId,
|
||||
requestBinding.Id,
|
||||
KickoffInputText,
|
||||
KickoffMessageText,
|
||||
ResumeRegularText,
|
||||
ResumeProcessedText);
|
||||
ExecutorBinding startBinding = startExecutor.BindExecutor();
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(startBinding)
|
||||
.AddEdge<List<ChatMessage>>(startBinding, requestBinding, messages =>
|
||||
messages?.Any(message => message.Contents.OfType<TextContent>().Any(content => content.Text == KickoffMessageText)) == true)
|
||||
.AddEdge<TurnToken>(startBinding, requestBinding, _ => true)
|
||||
.Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.User, KickoffInputText),
|
||||
session).ToListAsync();
|
||||
FunctionCallContent emittedRequest = firstCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Single();
|
||||
|
||||
ChatMessage[] resumeMessages =
|
||||
[
|
||||
new(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]),
|
||||
new(ChatRole.User, ResumeRegularText)
|
||||
];
|
||||
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(resumeMessages, session).ToListAsync();
|
||||
List<string> textContents = [.. secondCallUpdates.SelectMany(update => update.Contents.OfType<TextContent>()).Select(content => content.Text)];
|
||||
|
||||
textContents.Should().Contain(ResumeProcessedText, "the start executor should receive an explicit TurnToken when the matched response wakes a different executor");
|
||||
textContents.Should().Contain("Request processed", "the matched external response should still be delivered to the downstream request owner");
|
||||
secondCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Should()
|
||||
.NotContain(c => c.CallId == emittedRequest.CallId, "the handled external request should not be re-emitted while waking the start executor");
|
||||
secondCallUpdates.SelectMany(u => u.Contents.OfType<ErrorContent>()).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_UnmatchedResponse_TriggersTurnAndKeepsProgressingAsync()
|
||||
{
|
||||
const string CallId = "unmatched-response-call-id";
|
||||
const string FunctionName = "unmatchedResponseFunction";
|
||||
|
||||
RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: false);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "Start"), session).ToListAsync();
|
||||
firstCallUpdates.Should().Contain(u => u.Contents.Any(c => c is FunctionCallContent));
|
||||
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("different-call-id", "tool output")]),
|
||||
session).ToListAsync();
|
||||
|
||||
int functionCallCount = secondCallUpdates
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Count(c => c.CallId == CallId);
|
||||
|
||||
functionCallCount.Should().Be(1, "an unmatched response should be treated as regular input and still drive a TurnToken continuation without workflow errors");
|
||||
secondCallUpdates.SelectMany(u => u.Contents.OfType<ErrorContent>()).Should().BeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when a resume contains only an external response directed at a non-start executor
|
||||
/// (no regular messages), the start executor still receives a TurnToken and is activated.
|
||||
/// This is a regression test for the case where the TurnToken was previously skipped because
|
||||
/// <c>HasRegularMessages</c> was <see langword="false"/>, leaving the start executor dormant.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_ResponseOnlyToNonStartExecutor_StartExecutorIsStillActivatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string StartExecutorId = "start-executor";
|
||||
const string ActivatedMarker = "start-executor-activated";
|
||||
const string CallId = "response-only-call-id";
|
||||
const string FunctionName = "responseOnlyFunction";
|
||||
|
||||
RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: true);
|
||||
ExecutorBinding requestBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
|
||||
TurnTrackingStartExecutor startExecutor = new(StartExecutorId, requestBinding.Id, ActivatedMarker);
|
||||
ExecutorBinding startBinding = startExecutor.BindExecutor();
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(startBinding)
|
||||
.AddEdge<List<ChatMessage>>(startBinding, requestBinding, messages =>
|
||||
messages?.Any(m => m.Contents.OfType<TextContent>().Any()) == true)
|
||||
.AddEdge<TurnToken>(startBinding, requestBinding, _ => true)
|
||||
.Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
// Act 1: First call triggers the downstream FunctionCallContent request
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.User, "Start"),
|
||||
session).ToListAsync();
|
||||
|
||||
FunctionCallContent emittedRequest = firstCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Single();
|
||||
|
||||
// Act 2: Resume with ONLY the external response (no regular messages)
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]),
|
||||
session).ToListAsync();
|
||||
|
||||
// Assert: Both the downstream and start executor should have been activated
|
||||
List<string> textContents = [.. secondCallUpdates
|
||||
.SelectMany(u => u.Contents.OfType<TextContent>())
|
||||
.Select(c => c.Text)];
|
||||
|
||||
textContents.Should().Contain("Request processed",
|
||||
"the downstream executor should process the external response");
|
||||
textContents.Should().Contain(ActivatedMarker,
|
||||
"the start executor should receive a TurnToken and be activated even when resume contains only an external response");
|
||||
secondCallUpdates
|
||||
.SelectMany(u => u.Contents.OfType<ErrorContent>())
|
||||
.Should()
|
||||
.BeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user