Merge branch 'main' into copilot/add-preconfigured-compaction-strategy

This commit is contained in:
Chris
2026-03-25 14:32:18 -07:00
committed by GitHub
Unverified
563 changed files with 15833 additions and 12878 deletions
@@ -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()
{
@@ -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 = [];
@@ -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]);
}
}
@@ -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)
{
@@ -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;
}
}
@@ -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();
}
}