mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into dev/dotnet_workflow/Enable-HandoffHILReturnToPrevious
This commit is contained in:
@@ -18,6 +18,7 @@ using OpenTelemetry.Trace;
|
||||
|
||||
#region Setup Telemetry
|
||||
|
||||
// Source name for this sample's custom ActivitySource and Meter; other instrumentation uses their own sources/categories.
|
||||
const string SourceName = "OpenTelemetryAspire.ConsoleApp";
|
||||
const string ServiceName = "AgentOpenTelemetry";
|
||||
|
||||
@@ -40,7 +41,6 @@ var resource = ResourceBuilder.CreateDefault()
|
||||
var tracerProviderBuilder = Sdk.CreateTracerProviderBuilder()
|
||||
.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0"))
|
||||
.AddSource(SourceName) // Our custom activity source
|
||||
.AddSource("*Microsoft.Agents.AI") // Agent Framework telemetry
|
||||
.AddHttpClientInstrumentation() // Capture HTTP calls to OpenAI
|
||||
.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint));
|
||||
|
||||
@@ -54,8 +54,7 @@ using var tracerProvider = tracerProviderBuilder.Build();
|
||||
// Setup metrics with resource and instrument name filtering
|
||||
using var meterProvider = Sdk.CreateMeterProviderBuilder()
|
||||
.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0"))
|
||||
.AddMeter(SourceName) // Our custom meter
|
||||
.AddMeter("*Microsoft.Agents.AI") // Agent Framework metrics
|
||||
.AddMeter(SourceName) // Our custom meter source
|
||||
.AddHttpClientInstrumentation() // HTTP client metrics
|
||||
.AddRuntimeInstrumentation() // .NET runtime metrics
|
||||
.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint))
|
||||
@@ -128,7 +127,7 @@ var agent = new ChatClientAgent(instrumentedChatClient,
|
||||
instructions: "You are a helpful assistant that provides concise and informative responses.",
|
||||
tools: [AIFunctionFactory.Create(GetWeatherAsync)])
|
||||
.AsBuilder()
|
||||
.UseOpenTelemetry(SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level
|
||||
.UseOpenTelemetry(sourceName: SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level
|
||||
.Build();
|
||||
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
+16
-4
@@ -73,16 +73,28 @@ foreach (ClientResult result in getConversationItemsResults.GetRawPages())
|
||||
using JsonDocument getConversationItemsResultAsJson = JsonDocument.Parse(result.GetRawResponse().Content.ToString());
|
||||
foreach (JsonElement element in getConversationItemsResultAsJson.RootElement.GetProperty("data").EnumerateArray())
|
||||
{
|
||||
// Skip non-message items (e.g. tool calls, reasoning) that lack a "role" property
|
||||
if (!element.TryGetProperty("role"u8, out var roleElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string messageId = element.GetProperty("id"u8).ToString();
|
||||
string messageRole = element.GetProperty("role"u8).ToString();
|
||||
string messageRole = roleElement.ToString();
|
||||
Console.WriteLine($" Message ID: {messageId}");
|
||||
Console.WriteLine($" Message Role: {messageRole}");
|
||||
|
||||
foreach (var content in element.GetProperty("content").EnumerateArray())
|
||||
if (element.TryGetProperty("content"u8, out var contentElement))
|
||||
{
|
||||
string messageContentText = content.GetProperty("text"u8).ToString();
|
||||
Console.WriteLine($" Message Text: {messageContentText}");
|
||||
foreach (var content in contentElement.EnumerateArray())
|
||||
{
|
||||
if (content.TryGetProperty("text"u8, out var textElement))
|
||||
{
|
||||
Console.WriteLine($" Message Text: {textElement}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -16,5 +16,11 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Assets\walkway.jpg">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -22,7 +22,7 @@ var agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential(
|
||||
|
||||
ChatMessage message = new(ChatRole.User, [
|
||||
new TextContent("What do you see in this image?"),
|
||||
new UriContent("https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", "image/jpeg")
|
||||
await DataContent.LoadFromAsync("Assets/walkway.jpg"),
|
||||
]);
|
||||
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
@@ -24,7 +24,7 @@ AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: VisionName, model
|
||||
|
||||
ChatMessage message = new(ChatRole.User, [
|
||||
new TextContent("What do you see in this image?"),
|
||||
await DataContent.LoadFromAsync("assets/walkway.jpg"),
|
||||
await DataContent.LoadFromAsync("Assets/walkway.jpg"),
|
||||
]);
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
+7
-3
@@ -39,14 +39,16 @@ internal abstract record ChatCompletionRequestMessage
|
||||
/// <exception cref="InvalidOperationException">Thrown when the content is neither text nor AI contents.</exception>
|
||||
public virtual ChatMessage ToChatMessage()
|
||||
{
|
||||
var role = new ChatRole(this.Role);
|
||||
|
||||
if (this.Content.IsText)
|
||||
{
|
||||
return new(ChatRole.User, this.Content.Text);
|
||||
return new(role, this.Content.Text);
|
||||
}
|
||||
else if (this.Content.IsContents)
|
||||
{
|
||||
var aiContents = this.Content.Contents.Select(MessageContentPartConverter.ToAIContent).Where(c => c is not null).ToList();
|
||||
return new ChatMessage(ChatRole.User, aiContents!);
|
||||
return new ChatMessage(role, aiContents!);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("MessageContent has no value");
|
||||
@@ -165,9 +167,11 @@ internal sealed record FunctionMessage : ChatCompletionRequestMessage
|
||||
/// <exception cref="InvalidOperationException">Thrown when the content is not text.</exception>
|
||||
public override ChatMessage ToChatMessage()
|
||||
{
|
||||
var role = new ChatRole(this.Role);
|
||||
|
||||
if (this.Content.IsText)
|
||||
{
|
||||
return new(ChatRole.User, this.Content.Text);
|
||||
return new(role, this.Content.Text);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("FunctionMessage Content must be text");
|
||||
|
||||
@@ -788,6 +788,13 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
chatOptions.ConversationId = typedSession.ConversationId;
|
||||
}
|
||||
|
||||
// When per-service-call persistence is active, set a sentinel conversation ID so that
|
||||
// FunctionInvokingChatClient treats locally-persisted history the same as service-managed
|
||||
// history. This prevents it from adding duplicate FunctionCallContent messages into the
|
||||
// request when processing approval responses — the loaded history already contains them.
|
||||
// ChatHistoryPersistingChatClient strips the sentinel before forwarding to the inner client.
|
||||
chatOptions = this.SetLocalHistoryConversationIdIfNeeded(chatOptions);
|
||||
|
||||
// Materialize the accumulated messages once at the end of the provider pipeline, reusing the existing list if possible.
|
||||
List<ChatMessage> messagesList = inputMessagesForChatClient as List<ChatMessage> ?? inputMessagesForChatClient.ToList();
|
||||
|
||||
@@ -929,6 +936,26 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the <see cref="ChatHistoryPersistingChatClient.LocalHistoryConversationId"/> sentinel on
|
||||
/// <paramref name="chatOptions"/> when per-service-call persistence is active and no real
|
||||
/// conversation ID is present.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// The (possibly new) <see cref="ChatOptions"/> with the sentinel set, or the original
|
||||
/// <paramref name="chatOptions"/> if no sentinel is needed.
|
||||
/// </returns>
|
||||
private ChatOptions? SetLocalHistoryConversationIdIfNeeded(ChatOptions? chatOptions)
|
||||
{
|
||||
if (this.PersistsChatHistoryPerServiceCall && string.IsNullOrWhiteSpace(chatOptions?.ConversationId))
|
||||
{
|
||||
chatOptions ??= new ChatOptions();
|
||||
chatOptions.ConversationId = ChatHistoryPersistingChatClient.LocalHistoryConversationId;
|
||||
}
|
||||
|
||||
return chatOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the agent has a <see cref="ChatHistoryPersistingChatClient"/>
|
||||
/// decorator in mark-only mode, which marks messages for later persistence at the end of the run.
|
||||
|
||||
@@ -50,6 +50,26 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
|
||||
/// </summary>
|
||||
internal const string PersistedMarkerKey = "_chatHistoryPersisted";
|
||||
|
||||
/// <summary>
|
||||
/// A sentinel value set on <see cref="ChatOptions.ConversationId"/> by <see cref="ChatClientAgent"/>
|
||||
/// when per-service-call persistence is active and no real conversation ID exists.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This signals to <see cref="FunctionInvokingChatClient"/> that the chat history is being managed
|
||||
/// externally (by this decorator), which prevents it from adding duplicate <see cref="FunctionCallContent"/>
|
||||
/// messages into the request during approval-response processing. Without this sentinel,
|
||||
/// <see cref="FunctionInvokingChatClient"/> would reconstruct function-call messages from approval
|
||||
/// responses and append them to the original messages — but the loaded history already contains
|
||||
/// those same function calls, causing duplicate tool-call entries that the model rejects.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This decorator strips the sentinel before forwarding requests to the inner client, so the
|
||||
/// underlying model never sees it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal const string LocalHistoryConversationId = "_agent_local_history";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatHistoryPersistingChatClient"/> class.
|
||||
/// </summary>
|
||||
@@ -87,6 +107,7 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (agent, session) = GetRequiredAgentAndSession();
|
||||
options = StripLocalHistoryConversationId(options);
|
||||
|
||||
ChatResponse response;
|
||||
try
|
||||
@@ -130,6 +151,7 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (agent, session) = GetRequiredAgentAndSession();
|
||||
options = StripLocalHistoryConversationId(options);
|
||||
|
||||
List<ChatResponseUpdate> responseUpdates = [];
|
||||
|
||||
@@ -310,4 +332,20 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If the <paramref name="options"/> carry the <see cref="LocalHistoryConversationId"/> sentinel,
|
||||
/// returns a clone with the conversation ID cleared so the inner client never sees it.
|
||||
/// Otherwise returns the original <paramref name="options"/> unchanged.
|
||||
/// </summary>
|
||||
private static ChatOptions? StripLocalHistoryConversationId(ChatOptions? options)
|
||||
{
|
||||
if (options?.ConversationId == LocalHistoryConversationId)
|
||||
{
|
||||
options = options.Clone();
|
||||
options.ConversationId = null;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ChatCompletionRequestMessage.ToChatMessage() role preservation.
|
||||
/// Verifies that each message type correctly maps its role to the corresponding ChatRole.
|
||||
/// </summary>
|
||||
public sealed class ChatCompletionRequestMessageToChatMessageTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("system", """{"role":"system","content":"You are a helpful assistant."}""")]
|
||||
[InlineData("developer", """{"role":"developer","content":"Follow these rules."}""")]
|
||||
[InlineData("user", """{"role":"user","content":"Hello!"}""")]
|
||||
[InlineData("assistant", """{"role":"assistant","content":"Hi there!"}""")]
|
||||
[InlineData("tool", """{"role":"tool","content":"result","tool_call_id":"call_123"}""")]
|
||||
public void ToChatMessage_PreservesRole_ForTextContent(string expectedRole, string json)
|
||||
{
|
||||
// Arrange
|
||||
ChatCompletionRequestMessage message = JsonSerializer.Deserialize(
|
||||
json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!;
|
||||
|
||||
// Act
|
||||
ChatMessage chatMessage = message.ToChatMessage();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedRole, message.Role);
|
||||
Assert.Equal(new ChatRole(expectedRole), chatMessage.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToChatMessage_FunctionMessage_PreservesRole()
|
||||
{
|
||||
// Arrange
|
||||
const string Json = """{"role":"function","name":"get_weather","content":"sunny"}""";
|
||||
ChatCompletionRequestMessage message = JsonSerializer.Deserialize(
|
||||
Json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!;
|
||||
|
||||
// Act
|
||||
ChatMessage chatMessage = message.ToChatMessage();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("function", message.Role);
|
||||
Assert.Equal(new ChatRole("function"), chatMessage.Role);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("system")]
|
||||
[InlineData("developer")]
|
||||
[InlineData("user")]
|
||||
[InlineData("assistant")]
|
||||
public void ToChatMessage_PreservesRole_ForMultiPartContent(string expectedRole)
|
||||
{
|
||||
// Arrange
|
||||
string json = $$"""{"role":"{{expectedRole}}","content":[{"type":"text","text":"Hello!"}]}""";
|
||||
ChatCompletionRequestMessage message = JsonSerializer.Deserialize(
|
||||
json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!;
|
||||
|
||||
// Act
|
||||
ChatMessage chatMessage = message.ToChatMessage();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedRole, message.Role);
|
||||
Assert.Equal(new ChatRole(expectedRole), chatMessage.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToChatMessage_MultiTurnConversation_PreservesAllRoles()
|
||||
{
|
||||
// Arrange - simulate a multi-turn conversation
|
||||
string[] jsons =
|
||||
[
|
||||
"""{"role":"system","content":"You are a helpful assistant."}""",
|
||||
"""{"role":"user","content":"Hello!"}""",
|
||||
"""{"role":"assistant","content":"Hi there! How can I help?"}""",
|
||||
"""{"role":"user","content":"What did I just say?"}"""
|
||||
];
|
||||
|
||||
string[] expectedRoles = ["system", "user", "assistant", "user"];
|
||||
|
||||
// Act
|
||||
ChatMessage[] chatMessages = jsons
|
||||
.Select(j => JsonSerializer.Deserialize(
|
||||
j, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!)
|
||||
.Select(m => m.ToChatMessage())
|
||||
.ToArray();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedRoles.Length, chatMessages.Length);
|
||||
for (int i = 0; i < expectedRoles.Length; i++)
|
||||
{
|
||||
Assert.Equal(new ChatRole(expectedRoles[i]), chatMessages[i].Role);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToChatMessage_PreservesTextContent()
|
||||
{
|
||||
// Arrange
|
||||
const string Json = """{"role":"system","content":"You are a helpful assistant."}""";
|
||||
ChatCompletionRequestMessage message = JsonSerializer.Deserialize(
|
||||
Json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!;
|
||||
|
||||
// Act
|
||||
ChatMessage chatMessage = message.ToChatMessage();
|
||||
|
||||
// Assert
|
||||
Assert.Contains(chatMessage.Contents, c => c is TextContent tc && tc.Text == "You are a helpful assistant.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared test helper for <see cref="ChatClientAgent"/> integration tests that verify
|
||||
/// end-to-end behavior with <see cref="ChatHistoryPersistingChatClient"/> and
|
||||
/// <see cref="FunctionInvokingChatClient"/>.
|
||||
/// </summary>
|
||||
internal static class ChatClientAgentTestHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents an expected service call during a test: an optional input verifier and the response to return.
|
||||
/// </summary>
|
||||
/// <param name="Response">The <see cref="ChatResponse"/> the mock service should return for this call.</param>
|
||||
/// <param name="VerifyInput">Optional callback to verify the messages sent to the service on this call.</param>
|
||||
#pragma warning disable CA1812 // Instantiated by test classes
|
||||
public sealed record ServiceCallExpectation(
|
||||
ChatResponse Response,
|
||||
Action<List<ChatMessage>>? VerifyInput = null);
|
||||
#pragma warning restore CA1812
|
||||
|
||||
/// <summary>
|
||||
/// Describes the expected shape of a message in the persisted history for structural comparison.
|
||||
/// </summary>
|
||||
/// <param name="Role">The expected role of the message.</param>
|
||||
/// <param name="TextContains">Optional substring that the message text should contain.</param>
|
||||
/// <param name="ContentTypes">Optional array of expected <see cref="AIContent"/> types in the message.</param>
|
||||
#pragma warning disable CA1812 // Instantiated by test classes
|
||||
public sealed record ExpectedMessage(
|
||||
ChatRole Role,
|
||||
string? TextContains = null,
|
||||
Type[]? ContentTypes = null);
|
||||
#pragma warning restore CA1812
|
||||
|
||||
/// <summary>
|
||||
/// The result of a RunAsync invocation, containing the response, session, agent,
|
||||
/// captured service inputs, and call counts for detailed verification.
|
||||
/// </summary>
|
||||
public sealed record RunResult(
|
||||
AgentResponse Response,
|
||||
ChatClientAgentSession Session,
|
||||
ChatClientAgent Agent,
|
||||
Mock<IChatClient> MockService,
|
||||
int TotalServiceCalls,
|
||||
List<List<ChatMessage>> CapturedServiceInputs);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock <see cref="IChatClient"/> that returns responses in sequence,
|
||||
/// captures input messages, and optionally verifies inputs.
|
||||
/// </summary>
|
||||
/// <param name="expectations">The ordered sequence of expected service calls.</param>
|
||||
/// <param name="callIndex">Shared call index counter (allows reuse across multiple RunAsync calls).</param>
|
||||
/// <param name="capturedInputs">List that captured service inputs are appended to.</param>
|
||||
/// <returns>The configured mock.</returns>
|
||||
public static Mock<IChatClient> CreateSequentialMock(
|
||||
List<ServiceCallExpectation> expectations,
|
||||
Ref<int> callIndex,
|
||||
List<List<ChatMessage>> capturedInputs)
|
||||
{
|
||||
Mock<IChatClient> mock = new();
|
||||
mock.Setup(s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) =>
|
||||
{
|
||||
int idx = callIndex.Value++;
|
||||
var messageList = msgs.ToList();
|
||||
capturedInputs.Add(messageList);
|
||||
|
||||
if (idx >= expectations.Count)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Mock received unexpected service call #{idx + 1}. Only {expectations.Count} call(s) were expected.");
|
||||
}
|
||||
|
||||
var expectation = expectations[idx];
|
||||
expectation.VerifyInput?.Invoke(messageList);
|
||||
return Task.FromResult(expectation.Response);
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with the given inputs, automatically verifying service call count
|
||||
/// and optional expected history, and returns the result for further assertions.
|
||||
/// </summary>
|
||||
/// <param name="inputMessages">Messages to pass to RunAsync.</param>
|
||||
/// <param name="serviceCallExpectations">Ordered service call expectations for the mock.</param>
|
||||
/// <param name="agentOptions">Options for configuring the agent. If null, defaults are used.</param>
|
||||
/// <param name="existingSession">An existing session to reuse (for multi-turn tests). If null, a new session is created.</param>
|
||||
/// <param name="existingAgent">An existing agent to reuse (for multi-turn tests). If null, a new agent is created.</param>
|
||||
/// <param name="existingMock">An existing mock to reuse (for multi-turn tests). If null, a new mock is created.</param>
|
||||
/// <param name="callIndex">Shared call index for multi-turn tests. If null, a new counter is created.</param>
|
||||
/// <param name="capturedInputs">Shared captured inputs list for multi-turn tests. If null, a new list is created.</param>
|
||||
/// <param name="initialChatHistory">Optional initial chat history to pre-populate in <see cref="InMemoryChatHistoryProvider"/>.</param>
|
||||
/// <param name="runOptions">Optional <see cref="AgentRunOptions"/> to pass to RunAsync.</param>
|
||||
/// <param name="expectedServiceCallCount">
|
||||
/// If provided, asserts the total number of service calls matches.
|
||||
/// For multi-turn tests, pass null and verify after the final turn.
|
||||
/// </param>
|
||||
/// <param name="expectedHistory">
|
||||
/// If provided, asserts that the persisted history matches these expected messages.
|
||||
/// For multi-turn tests, pass null and verify after the final turn.
|
||||
/// </param>
|
||||
/// <returns>A <see cref="RunResult"/> containing the response, session, agent, mock, and captured inputs.</returns>
|
||||
public static async Task<RunResult> RunAsync(
|
||||
List<ChatMessage> inputMessages,
|
||||
List<ServiceCallExpectation> serviceCallExpectations,
|
||||
ChatClientAgentOptions? agentOptions = null,
|
||||
ChatClientAgentSession? existingSession = null,
|
||||
ChatClientAgent? existingAgent = null,
|
||||
Mock<IChatClient>? existingMock = null,
|
||||
Ref<int>? callIndex = null,
|
||||
List<List<ChatMessage>>? capturedInputs = null,
|
||||
List<ChatMessage>? initialChatHistory = null,
|
||||
AgentRunOptions? runOptions = null,
|
||||
int? expectedServiceCallCount = null,
|
||||
List<ExpectedMessage>? expectedHistory = null)
|
||||
{
|
||||
callIndex ??= new Ref<int>(0);
|
||||
capturedInputs ??= [];
|
||||
var mock = existingMock ?? CreateSequentialMock(serviceCallExpectations, callIndex, capturedInputs);
|
||||
agentOptions ??= new ChatClientAgentOptions();
|
||||
|
||||
var agent = existingAgent ?? new ChatClientAgent(
|
||||
mock.Object,
|
||||
options: agentOptions,
|
||||
services: new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
var session = existingSession ?? (await agent.CreateSessionAsync() as ChatClientAgentSession)!;
|
||||
|
||||
// Pre-populate initial chat history if provided.
|
||||
if (initialChatHistory is not null)
|
||||
{
|
||||
(agent.ChatHistoryProvider as InMemoryChatHistoryProvider)
|
||||
?.SetMessages(session, new List<ChatMessage>(initialChatHistory));
|
||||
}
|
||||
|
||||
var response = await agent.RunAsync(inputMessages, session, runOptions);
|
||||
|
||||
var result = new RunResult(response, session, agent, mock, callIndex.Value, capturedInputs);
|
||||
|
||||
// Auto-verify service call count if specified.
|
||||
if (expectedServiceCallCount.HasValue)
|
||||
{
|
||||
Assert.Equal(expectedServiceCallCount.Value, callIndex.Value);
|
||||
}
|
||||
|
||||
// Auto-verify persisted history if specified.
|
||||
if (expectedHistory is not null)
|
||||
{
|
||||
var history = GetPersistedHistory(agent, session);
|
||||
AssertMessagesMatch(history, expectedHistory);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that the actual message list matches the expected message patterns structurally.
|
||||
/// Checks message count, roles, optional text content, and optional content types.
|
||||
/// </summary>
|
||||
/// <param name="actual">The actual messages to verify.</param>
|
||||
/// <param name="expected">The expected message patterns.</param>
|
||||
public static void AssertMessagesMatch(List<ChatMessage> actual, List<ExpectedMessage> expected)
|
||||
{
|
||||
Assert.True(
|
||||
expected.Count == actual.Count,
|
||||
$"Expected {expected.Count} message(s) but found {actual.Count}.\nActual messages:\n{FormatMessages(actual)}");
|
||||
|
||||
for (int i = 0; i < expected.Count; i++)
|
||||
{
|
||||
var exp = expected[i];
|
||||
var act = actual[i];
|
||||
|
||||
Assert.True(
|
||||
exp.Role == act.Role,
|
||||
$"Message [{i}]: expected role {exp.Role} but found {act.Role}.\nActual messages:\n{FormatMessages(actual)}");
|
||||
|
||||
if (exp.TextContains is not null)
|
||||
{
|
||||
Assert.Contains(exp.TextContains, act.Text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
if (exp.ContentTypes is not null)
|
||||
{
|
||||
AssertContentTypes(act.Contents, exp.ContentTypes, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the persisted chat history from the agent's <see cref="InMemoryChatHistoryProvider"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent whose history provider to query.</param>
|
||||
/// <param name="session">The session to get history for.</param>
|
||||
/// <returns>The list of persisted messages, or an empty list if no provider is available.</returns>
|
||||
public static List<ChatMessage> GetPersistedHistory(ChatClientAgent agent, AgentSession session)
|
||||
{
|
||||
var provider = agent.ChatHistoryProvider as InMemoryChatHistoryProvider;
|
||||
return provider?.GetMessages(session) ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats the contents of a message list as a diagnostic string for test failure messages.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to format.</param>
|
||||
/// <returns>A human-readable representation of the messages.</returns>
|
||||
public static string FormatMessages(IEnumerable<ChatMessage> messages)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
int index = 0;
|
||||
foreach (var msg in messages)
|
||||
{
|
||||
sb.AppendLine($" [{index}] Role={msg.Role}, Text=\"{msg.Text}\", Contents=[{string.Join(", ", msg.Contents.Select(c => c.GetType().Name))}]");
|
||||
index++;
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple mutable reference wrapper for value types, allowing shared state across callbacks.
|
||||
/// </summary>
|
||||
public sealed class Ref<T>(T value) where T : struct
|
||||
{
|
||||
public T Value { get; set; } = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that a message's content collection contains the expected content types.
|
||||
/// </summary>
|
||||
private static void AssertContentTypes(IList<AIContent> contents, Type[] expectedTypes, int messageIndex)
|
||||
{
|
||||
Assert.True(
|
||||
contents.Count >= expectedTypes.Length,
|
||||
$"Message [{messageIndex}]: expected at least {expectedTypes.Length} content(s) but found {contents.Count}. " +
|
||||
$"Actual types: [{string.Join(", ", contents.Select(c => c.GetType().Name))}]");
|
||||
|
||||
foreach (var expectedType in expectedTypes)
|
||||
{
|
||||
Assert.True(
|
||||
contents.Any(c => expectedType.IsInstanceOfType(c)),
|
||||
$"Message [{messageIndex}]: expected content of type {expectedType.Name} but found [{string.Join(", ", contents.Select(c => c.GetType().Name))}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,18 +379,23 @@ public partial class ChatClientAgentTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync passes null ChatOptions when using regular AgentRunOptions.
|
||||
/// Verify that RunAsync passes ChatOptions with null ConversationId when using regular AgentRunOptions.
|
||||
/// When per-service-call persistence is active (default), the sentinel conversation ID is set on ChatOptions
|
||||
/// and then stripped by ChatHistoryPersistingChatClient before reaching the inner client.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsyncPassesNullChatOptionsWhenUsingRegularAgentRunOptionsAsync()
|
||||
public async Task RunAsyncPassesChatOptionsWithNullConversationIdWhenUsingRegularAgentRunOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatOptions? capturedOptions = null;
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
null,
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object);
|
||||
var runOptions = new AgentRunOptions();
|
||||
@@ -398,13 +403,9 @@ public partial class ChatClientAgentTests
|
||||
// Act
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], options: runOptions);
|
||||
|
||||
// Assert
|
||||
mockService.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
null,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
// Assert — the inner client receives ChatOptions with null ConversationId (sentinel was stripped)
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Null(capturedOptions!.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains unit tests that verify the end-to-end approval flow behavior of the
|
||||
/// <see cref="ChatClientAgent"/> class with <see cref="ChatHistoryPersistingChatClient"/>,
|
||||
/// ensuring that chat history is correctly persisted across multi-turn approval interactions.
|
||||
/// </summary>
|
||||
public class ChatClientAgent_ApprovalsTests
|
||||
{
|
||||
#region Per-Service-Call Persistence Approval Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that with per-service-call persistence and an approval-required tool,
|
||||
/// a two-turn approval flow persists the correct final history:
|
||||
/// Turn 1: user asks → model returns FCC → FICC converts to ToolApprovalRequestContent → returned to caller.
|
||||
/// Turn 2: caller sends ToolApprovalResponseContent → FICC processes approval, invokes function, calls model again.
|
||||
/// Final history: [user, assistant(FCC), tool(FRC), assistant(final)].
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ApprovalRequired_PerServiceCallPersistence_PersistsCorrectHistoryAsync()
|
||||
{
|
||||
// Arrange
|
||||
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(tool);
|
||||
|
||||
var callIndex = new ChatClientAgentTestHelper.Ref<int>(0);
|
||||
var capturedInputs = new List<List<ChatMessage>>();
|
||||
var serviceExpectations = new List<ChatClientAgentTestHelper.ServiceCallExpectation>
|
||||
{
|
||||
// Turn 1: model returns a function call (FICC will convert to approval request)
|
||||
new(new ChatResponse([new(ChatRole.Assistant,
|
||||
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
|
||||
// Turn 2: after approval, FICC invokes the function and calls the model again
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])),
|
||||
};
|
||||
|
||||
// Act — Turn 1: initial request
|
||||
var result1 = await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "What's the weather?")],
|
||||
serviceCallExpectations: serviceExpectations,
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [approvalTool] },
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
},
|
||||
callIndex: callIndex,
|
||||
capturedInputs: capturedInputs);
|
||||
|
||||
// Verify Turn 1 returns exactly one approval request
|
||||
var approvalRequests = result1.Response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.ToList();
|
||||
Assert.Single(approvalRequests);
|
||||
Assert.Equal(1, result1.TotalServiceCalls);
|
||||
|
||||
// Verify service received user message on first call
|
||||
Assert.Single(capturedInputs);
|
||||
Assert.Contains(capturedInputs[0], m => m.Role == ChatRole.User && m.Text == "What's the weather?");
|
||||
|
||||
// Act — Turn 2: send approval response
|
||||
var approvalResponseMessages = approvalRequests.ConvertAll(req =>
|
||||
new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)]));
|
||||
|
||||
await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: approvalResponseMessages,
|
||||
serviceCallExpectations: serviceExpectations,
|
||||
existingSession: result1.Session,
|
||||
existingAgent: result1.Agent,
|
||||
existingMock: result1.MockService,
|
||||
callIndex: callIndex,
|
||||
capturedInputs: capturedInputs,
|
||||
expectedServiceCallCount: 2,
|
||||
expectedHistory:
|
||||
[
|
||||
new(ChatRole.User, TextContains: "What's the weather?"),
|
||||
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
|
||||
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
|
||||
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
|
||||
]);
|
||||
|
||||
// Verify second service call received the full conversation (user + FCC + FRC)
|
||||
Assert.Equal(2, capturedInputs.Count);
|
||||
Assert.Contains(capturedInputs[1], m => m.Contents.OfType<FunctionCallContent>().Any());
|
||||
Assert.Contains(capturedInputs[1], m => m.Contents.OfType<FunctionResultContent>().Any());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region End-of-Run Persistence Approval Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that with end-of-run persistence and an approval-required tool,
|
||||
/// a two-turn approval flow persists the correct final history.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ApprovalRequired_EndOfRunPersistence_PersistsCorrectHistoryAsync()
|
||||
{
|
||||
// Arrange
|
||||
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(tool);
|
||||
|
||||
var callIndex = new ChatClientAgentTestHelper.Ref<int>(0);
|
||||
var capturedInputs = new List<List<ChatMessage>>();
|
||||
var serviceExpectations = new List<ChatClientAgentTestHelper.ServiceCallExpectation>
|
||||
{
|
||||
new(new ChatResponse([new(ChatRole.Assistant,
|
||||
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])),
|
||||
};
|
||||
|
||||
// Act — Turn 1
|
||||
var result1 = await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "What's the weather?")],
|
||||
serviceCallExpectations: serviceExpectations,
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [approvalTool] },
|
||||
PersistChatHistoryAtEndOfRun = true,
|
||||
},
|
||||
callIndex: callIndex,
|
||||
capturedInputs: capturedInputs);
|
||||
|
||||
var approvalRequests = result1.Response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.ToList();
|
||||
Assert.Single(approvalRequests);
|
||||
|
||||
// Act — Turn 2
|
||||
var approvalResponseMessages = approvalRequests.ConvertAll(req =>
|
||||
new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)]));
|
||||
|
||||
var result2 = await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: approvalResponseMessages,
|
||||
serviceCallExpectations: serviceExpectations,
|
||||
existingSession: result1.Session,
|
||||
existingAgent: result1.Agent,
|
||||
existingMock: result1.MockService,
|
||||
callIndex: callIndex,
|
||||
capturedInputs: capturedInputs,
|
||||
expectedServiceCallCount: 2,
|
||||
expectedHistory:
|
||||
[
|
||||
// End-of-run persistence retains the approval request from Turn 1
|
||||
new(ChatRole.User, TextContains: "What's the weather?"),
|
||||
new(ChatRole.Assistant, ContentTypes: [typeof(ToolApprovalRequestContent)]),
|
||||
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
|
||||
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
|
||||
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
|
||||
]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Service-Stored History Approval Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that with service-stored history (ConversationId returned) and an approval-required tool,
|
||||
/// the two-turn approval flow completes without errors and the session gets the ConversationId.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ApprovalRequired_ServiceStoredHistory_CompletesWithoutErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ConversationId = "thread-456";
|
||||
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(tool);
|
||||
|
||||
var callIndex = new ChatClientAgentTestHelper.Ref<int>(0);
|
||||
var capturedInputs = new List<List<ChatMessage>>();
|
||||
var serviceExpectations = new List<ChatClientAgentTestHelper.ServiceCallExpectation>
|
||||
{
|
||||
new(new ChatResponse([new(ChatRole.Assistant,
|
||||
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])
|
||||
{
|
||||
ConversationId = ConversationId,
|
||||
}),
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])
|
||||
{
|
||||
ConversationId = ConversationId,
|
||||
}),
|
||||
};
|
||||
|
||||
// Act — Turn 1
|
||||
var result1 = await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "What's the weather?")],
|
||||
serviceCallExpectations: serviceExpectations,
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [approvalTool] },
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
},
|
||||
callIndex: callIndex,
|
||||
capturedInputs: capturedInputs);
|
||||
|
||||
var approvalRequests = result1.Response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.ToList();
|
||||
Assert.Single(approvalRequests);
|
||||
Assert.Equal(ConversationId, result1.Session.ConversationId);
|
||||
|
||||
// Act — Turn 2
|
||||
var approvalResponseMessages = approvalRequests.ConvertAll(req =>
|
||||
new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)]));
|
||||
|
||||
var result2 = await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: approvalResponseMessages,
|
||||
serviceCallExpectations: serviceExpectations,
|
||||
existingSession: result1.Session,
|
||||
existingAgent: result1.Agent,
|
||||
existingMock: result1.MockService,
|
||||
callIndex: callIndex,
|
||||
capturedInputs: capturedInputs,
|
||||
expectedServiceCallCount: 2);
|
||||
|
||||
// Assert — session should retain the ConversationId, response should be correct
|
||||
Assert.Equal(ConversationId, result2.Session.ConversationId);
|
||||
Assert.Contains(result2.Response.Messages, m => m.Text == "The weather in Amsterdam is sunny and 22°C.");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Approval Rejected Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when an approval is rejected, the rejection result is persisted in the history
|
||||
/// and the model receives the rejection information.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ApprovalRejected_PersistsRejectionInHistoryAsync()
|
||||
{
|
||||
// Arrange
|
||||
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
|
||||
var approvalTool = new ApprovalRequiredAIFunction(tool);
|
||||
|
||||
var callIndex = new ChatClientAgentTestHelper.Ref<int>(0);
|
||||
var capturedInputs = new List<List<ChatMessage>>();
|
||||
var serviceExpectations = new List<ChatClientAgentTestHelper.ServiceCallExpectation>
|
||||
{
|
||||
// Turn 1: model requests function call
|
||||
new(new ChatResponse([new(ChatRole.Assistant,
|
||||
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
|
||||
// Turn 2: after rejection, model gets the rejection info and responds accordingly
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "I'm sorry, I cannot check the weather without your approval.")])),
|
||||
};
|
||||
|
||||
// Act — Turn 1
|
||||
var result1 = await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "What's the weather?")],
|
||||
serviceCallExpectations: serviceExpectations,
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [approvalTool] },
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
},
|
||||
callIndex: callIndex,
|
||||
capturedInputs: capturedInputs);
|
||||
|
||||
var approvalRequests = result1.Response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.ToList();
|
||||
Assert.Single(approvalRequests);
|
||||
|
||||
// Act — Turn 2: reject the approval
|
||||
var rejectionMessages = approvalRequests.ConvertAll(req =>
|
||||
new ChatMessage(ChatRole.User, [req.CreateResponse(approved: false, reason: "User declined")]));
|
||||
|
||||
var result2 = await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: rejectionMessages,
|
||||
serviceCallExpectations: serviceExpectations,
|
||||
existingSession: result1.Session,
|
||||
existingAgent: result1.Agent,
|
||||
existingMock: result1.MockService,
|
||||
callIndex: callIndex,
|
||||
capturedInputs: capturedInputs,
|
||||
expectedServiceCallCount: 2);
|
||||
|
||||
// Assert — history should contain the rejection result (FRC with rejection)
|
||||
var history = ChatClientAgentTestHelper.GetPersistedHistory(result2.Agent, result2.Session);
|
||||
Assert.True(
|
||||
history.Count >= 3,
|
||||
$"Expected at least 3 messages in history, got {history.Count}.\n{ChatClientAgentTestHelper.FormatMessages(history)}");
|
||||
Assert.Contains(history, m => m.Role == ChatRole.User && m.Text == "What's the weather?");
|
||||
Assert.Contains(history, m => m.Contents.OfType<FunctionResultContent>().Any(
|
||||
frc => frc.Result?.ToString()?.Contains("rejected") == true));
|
||||
Assert.Contains(history, m => m.Role == ChatRole.Assistant &&
|
||||
m.Text == "I'm sorry, I cannot check the weather without your approval.");
|
||||
|
||||
// Verify the second service call received the rejection FRC
|
||||
Assert.Equal(2, capturedInputs.Count);
|
||||
Assert.Contains(capturedInputs[1], m => m.Contents.OfType<FunctionResultContent>().Any(
|
||||
frc => frc.Result?.ToString()?.Contains("rejected") == true));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+154
@@ -500,4 +500,158 @@ public class ChatClientAgent_ChatHistoryManagementTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region End-to-End Chat History Persistence Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that with per-service-call persistence (default), a simple request/response
|
||||
/// results in the correct chat history being persisted: [user, assistant].
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_PerServiceCallPersistence_SimpleResponse_PersistsCorrectHistoryAsync()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "Hello")],
|
||||
serviceCallExpectations:
|
||||
[
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "Hi there")])),
|
||||
],
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "Be helpful" },
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
},
|
||||
expectedServiceCallCount: 1,
|
||||
expectedHistory:
|
||||
[
|
||||
new(ChatRole.User, TextContains: "Hello"),
|
||||
new(ChatRole.Assistant, TextContains: "Hi there"),
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that with per-service-call persistence and a function calling loop,
|
||||
/// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)].
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_PerServiceCallPersistence_FunctionCallingLoop_PersistsCorrectHistoryAsync()
|
||||
{
|
||||
// Arrange
|
||||
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
|
||||
|
||||
// Act & Assert
|
||||
await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "What's the weather?")],
|
||||
serviceCallExpectations:
|
||||
[
|
||||
// First call: model requests a function call
|
||||
new(new ChatResponse([new(ChatRole.Assistant,
|
||||
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
|
||||
// Second call: model returns final response after seeing function result
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])),
|
||||
],
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [tool] },
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
},
|
||||
expectedServiceCallCount: 2,
|
||||
expectedHistory:
|
||||
[
|
||||
new(ChatRole.User, TextContains: "What's the weather?"),
|
||||
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
|
||||
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
|
||||
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that with end-of-run persistence, a simple request/response
|
||||
/// results in the correct chat history being persisted: [user, assistant].
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_EndOfRunPersistence_SimpleResponse_PersistsCorrectHistoryAsync()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "Hello")],
|
||||
serviceCallExpectations:
|
||||
[
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "Hi there")])),
|
||||
],
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "Be helpful" },
|
||||
PersistChatHistoryAtEndOfRun = true,
|
||||
},
|
||||
expectedServiceCallCount: 1,
|
||||
expectedHistory:
|
||||
[
|
||||
new(ChatRole.User, TextContains: "Hello"),
|
||||
new(ChatRole.Assistant, TextContains: "Hi there"),
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that with end-of-run persistence and a function calling loop,
|
||||
/// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)].
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_EndOfRunPersistence_FunctionCallingLoop_PersistsCorrectHistoryAsync()
|
||||
{
|
||||
// Arrange
|
||||
var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather");
|
||||
|
||||
// Act & Assert
|
||||
await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "What's the weather?")],
|
||||
serviceCallExpectations:
|
||||
[
|
||||
new(new ChatResponse([new(ChatRole.Assistant,
|
||||
[new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Amsterdam" })])])),
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])),
|
||||
],
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [tool] },
|
||||
PersistChatHistoryAtEndOfRun = true,
|
||||
},
|
||||
expectedServiceCallCount: 2,
|
||||
expectedHistory:
|
||||
[
|
||||
new(ChatRole.User, TextContains: "What's the weather?"),
|
||||
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
|
||||
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
|
||||
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the service returns a ConversationId (service-stored history),
|
||||
/// the session gets the ConversationId and no errors occur during the run.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ServiceStoredHistory_SetsConversationIdAndCompletesWithoutErrorAsync()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = await ChatClientAgentTestHelper.RunAsync(
|
||||
inputMessages: [new(ChatRole.User, "Hello")],
|
||||
serviceCallExpectations:
|
||||
[
|
||||
new(new ChatResponse([new(ChatRole.Assistant, "Hi there")]) { ConversationId = "thread-123" }),
|
||||
],
|
||||
agentOptions: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "Be helpful" },
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
},
|
||||
expectedServiceCallCount: 1);
|
||||
|
||||
// Assert — session should have the conversation id from the service
|
||||
Assert.Equal("thread-123", result.Session.ConversationId);
|
||||
Assert.Contains(result.Response.Messages, m => m.Text == "Hi there");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
+8
-5
@@ -176,10 +176,12 @@ public class ChatClientAgent_ChatOptionsMergingTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ChatOptions merging returns null when both agent and request have no ChatOptions.
|
||||
/// Verify that ChatOptions merging returns a non-null ChatOptions instance with null ConversationId
|
||||
/// when both agent and request have no ChatOptions. The sentinel conversation ID is set for
|
||||
/// per-service-call persistence and stripped before reaching the inner client.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChatOptionsMergingReturnsNullWhenBothAgentAndRequestHaveNoneAsync()
|
||||
public async Task ChatOptionsMergingReturnsChatOptionsWithNullConversationIdWhenBothAgentAndRequestHaveNoneAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
@@ -189,7 +191,7 @@ public class ChatClientAgent_ChatOptionsMergingTests
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, opts, ct) =>
|
||||
capturedChatOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
@@ -199,8 +201,9 @@ public class ChatClientAgent_ChatOptionsMergingTests
|
||||
// Act
|
||||
await agent.RunAsync(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Null(capturedChatOptions);
|
||||
// Assert — ChatOptions is non-null because the sentinel was set, but ConversationId is null (stripped)
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.Null(capturedChatOptions!.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+167
@@ -763,4 +763,171 @@ public class ChatHistoryPersistingChatClientTests
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when per-service-call persistence is active and no real conversation ID exists,
|
||||
/// <see cref="ChatClientAgent"/> sets the <see cref="ChatHistoryPersistingChatClient.LocalHistoryConversationId"/>
|
||||
/// sentinel on the chat options and <see cref="ChatHistoryPersistingChatClient"/> strips it before
|
||||
/// forwarding to the inner client.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatOptions? capturedOptions = null;
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test" },
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
await agent.RunAsync([new(ChatRole.User, "test")]);
|
||||
|
||||
// Assert — the inner client should NOT see the sentinel conversation ID
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Null(capturedOptions!.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the sentinel is NOT set when end-of-run persistence is enabled
|
||||
/// (mark-only mode), since the issue only applies to per-service-call persistence.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DoesNotSetSentinel_WhenEndOfRunPersistenceEnabledAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatOptions? capturedOptions = null;
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test" },
|
||||
PersistChatHistoryAtEndOfRun = true,
|
||||
});
|
||||
|
||||
// Act
|
||||
await agent.RunAsync([new(ChatRole.User, "test")]);
|
||||
|
||||
// Assert — the inner client should see options but NOT the sentinel conversation ID
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Null(capturedOptions!.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the sentinel is NOT set when a real conversation ID is already present
|
||||
/// on the session (indicating server-side history management).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DoesNotSetSentinel_WhenRealConversationIdExistsAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string RealConversationId = "real-conv-123";
|
||||
ChatOptions? capturedOptions = null;
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])
|
||||
{
|
||||
ConversationId = RealConversationId,
|
||||
});
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Create a session with a real conversation ID.
|
||||
var session = await agent.CreateSessionAsync(RealConversationId);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert — the inner client should see the real conversation ID, not the sentinel
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Equal(RealConversationId, capturedOptions!.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the sentinel is set and stripped correctly in the streaming path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatOptions? capturedOptions = null;
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts)
|
||||
.Returns(CreateAsyncEnumerableAsync(new ChatResponseUpdate(role: ChatRole.Assistant, content: "response")));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test" },
|
||||
PersistChatHistoryAtEndOfRun = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")]))
|
||||
{
|
||||
// Consume the stream.
|
||||
}
|
||||
|
||||
// Assert — the inner client should NOT see the sentinel conversation ID
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Null(capturedOptions!.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the session's conversation ID is NOT set to the sentinel after the run.
|
||||
/// The sentinel should only exist transiently on the ChatOptions for the pipeline.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_SentinelDoesNotLeakToSession_WhenPerServiceCallPersistenceActiveAsync()
|
||||
{
|
||||
// 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")]));
|
||||
|
||||
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 NOT have the sentinel conversation ID
|
||||
Assert.Null(session!.ConversationId);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -35,9 +35,9 @@ The backend uses Azure OpenAI responses and supports intent-driven, non-linear h
|
||||
From the Python repo root:
|
||||
|
||||
```bash
|
||||
cd /Users/evmattso/git/agent-framework/python
|
||||
cd python
|
||||
uv sync
|
||||
uv run python samples/demos/ag_ui_workflow_handoff/backend/server.py
|
||||
uv run python samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py
|
||||
```
|
||||
|
||||
Backend default URL:
|
||||
@@ -47,8 +47,10 @@ Backend default URL:
|
||||
|
||||
## 2) Install Frontend Packages (npm)
|
||||
|
||||
From the `python/` directory (where Step 1 left you):
|
||||
|
||||
```bash
|
||||
cd /Users/evmattso/git/agent-framework/python/samples/demos/ag_ui_workflow_handoff/frontend
|
||||
cd samples/05-end-to-end/ag_ui_workflow_handoff/frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# build artifacts
|
||||
*.tsbuildinfo
|
||||
vite.config.js
|
||||
vite.config.d.ts
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"}
|
||||
@@ -1,2 +0,0 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
@@ -1,11 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user