Merge branch 'main' into dev/dotnet_workflow/Enable-HandoffHILReturnToPrevious

This commit is contained in:
Jacob Alber
2026-03-26 14:14:53 -04:00
committed by GitHub
Unverified
33 changed files with 1132 additions and 47 deletions
@@ -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();
@@ -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();
}
}
@@ -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();
@@ -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;
}
}
@@ -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>
@@ -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
}
@@ -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
}
@@ -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>
@@ -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);
}
}
@@ -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,
},
});