diff --git a/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs b/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs
index 69d71e7b88..8e1f4245b6 100644
--- a/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs
+++ b/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs
@@ -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();
diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs
index 603f8b8e7b..a8dc73839a 100644
--- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs
+++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs
@@ -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();
}
}
diff --git a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj
index 73a41005f1..2b01c47354 100644
--- a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj
+++ b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj
@@ -16,5 +16,11 @@
+
+
+
+ Always
+
+
diff --git a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Assets/walkway.jpg b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Assets/walkway.jpg
new file mode 100644
index 0000000000..13ef1e1840
Binary files /dev/null and b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Assets/walkway.jpg differ
diff --git a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs
index 984a9e3b5c..08e5b63139 100644
--- a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs
+++ b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs
@@ -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();
diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs
index d44d62df51..d810c8046a 100644
--- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs
+++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs
@@ -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();
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs
index 3e9483c616..2433eacbe0 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs
@@ -39,14 +39,16 @@ internal abstract record ChatCompletionRequestMessage
/// Thrown when the content is neither text nor AI contents.
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
/// Thrown when the content is not text.
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");
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
index 6722bd8738..05caff4d83 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
@@ -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 messagesList = inputMessagesForChatClient as List ?? inputMessagesForChatClient.ToList();
@@ -929,6 +936,26 @@ public sealed partial class ChatClientAgent : AIAgent
}
}
+ ///
+ /// Sets the sentinel on
+ /// when per-service-call persistence is active and no real
+ /// conversation ID is present.
+ ///
+ ///
+ /// The (possibly new) with the sentinel set, or the original
+ /// if no sentinel is needed.
+ ///
+ private ChatOptions? SetLocalHistoryConversationIdIfNeeded(ChatOptions? chatOptions)
+ {
+ if (this.PersistsChatHistoryPerServiceCall && string.IsNullOrWhiteSpace(chatOptions?.ConversationId))
+ {
+ chatOptions ??= new ChatOptions();
+ chatOptions.ConversationId = ChatHistoryPersistingChatClient.LocalHistoryConversationId;
+ }
+
+ return chatOptions;
+ }
+
///
/// Gets a value indicating whether the agent has a
/// decorator in mark-only mode, which marks messages for later persistence at the end of the run.
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs
index 0085afbdd5..e733b778eb 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs
@@ -50,6 +50,26 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
///
internal const string PersistedMarkerKey = "_chatHistoryPersisted";
+ ///
+ /// A sentinel value set on by
+ /// when per-service-call persistence is active and no real conversation ID exists.
+ ///
+ ///
+ ///
+ /// This signals to that the chat history is being managed
+ /// externally (by this decorator), which prevents it from adding duplicate
+ /// messages into the request during approval-response processing. Without this sentinel,
+ /// 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.
+ ///
+ ///
+ /// This decorator strips the sentinel before forwarding requests to the inner client, so the
+ /// underlying model never sees it.
+ ///
+ ///
+ internal const string LocalHistoryConversationId = "_agent_local_history";
+
///
/// Initializes a new instance of the class.
///
@@ -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 responseUpdates = [];
@@ -310,4 +332,20 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
}
}
}
+
+ ///
+ /// If the carry the sentinel,
+ /// returns a clone with the conversation ID cleared so the inner client never sees it.
+ /// Otherwise returns the original unchanged.
+ ///
+ private static ChatOptions? StripLocalHistoryConversationId(ChatOptions? options)
+ {
+ if (options?.ConversationId == LocalHistoryConversationId)
+ {
+ options = options.Clone();
+ options.ConversationId = null;
+ }
+
+ return options;
+ }
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ChatCompletionRequestMessageToChatMessageTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ChatCompletionRequestMessageToChatMessageTests.cs
new file mode 100644
index 0000000000..406f0a32e1
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ChatCompletionRequestMessageToChatMessageTests.cs
@@ -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;
+
+///
+/// Tests for ChatCompletionRequestMessage.ToChatMessage() role preservation.
+/// Verifies that each message type correctly maps its role to the corresponding ChatRole.
+///
+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.");
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs
new file mode 100644
index 0000000000..a3d2bd0c6a
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs
@@ -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;
+
+///
+/// Shared test helper for integration tests that verify
+/// end-to-end behavior with and
+/// .
+///
+internal static class ChatClientAgentTestHelper
+{
+ ///
+ /// Represents an expected service call during a test: an optional input verifier and the response to return.
+ ///
+ /// The the mock service should return for this call.
+ /// Optional callback to verify the messages sent to the service on this call.
+#pragma warning disable CA1812 // Instantiated by test classes
+ public sealed record ServiceCallExpectation(
+ ChatResponse Response,
+ Action>? VerifyInput = null);
+#pragma warning restore CA1812
+
+ ///
+ /// Describes the expected shape of a message in the persisted history for structural comparison.
+ ///
+ /// The expected role of the message.
+ /// Optional substring that the message text should contain.
+ /// Optional array of expected types in the message.
+#pragma warning disable CA1812 // Instantiated by test classes
+ public sealed record ExpectedMessage(
+ ChatRole Role,
+ string? TextContains = null,
+ Type[]? ContentTypes = null);
+#pragma warning restore CA1812
+
+ ///
+ /// The result of a RunAsync invocation, containing the response, session, agent,
+ /// captured service inputs, and call counts for detailed verification.
+ ///
+ public sealed record RunResult(
+ AgentResponse Response,
+ ChatClientAgentSession Session,
+ ChatClientAgent Agent,
+ Mock MockService,
+ int TotalServiceCalls,
+ List> CapturedServiceInputs);
+
+ ///
+ /// Creates a mock that returns responses in sequence,
+ /// captures input messages, and optionally verifies inputs.
+ ///
+ /// The ordered sequence of expected service calls.
+ /// Shared call index counter (allows reuse across multiple RunAsync calls).
+ /// List that captured service inputs are appended to.
+ /// The configured mock.
+ public static Mock CreateSequentialMock(
+ List expectations,
+ Ref callIndex,
+ List> capturedInputs)
+ {
+ Mock mock = new();
+ mock.Setup(s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns, 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;
+ }
+
+ ///
+ /// Runs the agent with the given inputs, automatically verifying service call count
+ /// and optional expected history, and returns the result for further assertions.
+ ///
+ /// Messages to pass to RunAsync.
+ /// Ordered service call expectations for the mock.
+ /// Options for configuring the agent. If null, defaults are used.
+ /// An existing session to reuse (for multi-turn tests). If null, a new session is created.
+ /// An existing agent to reuse (for multi-turn tests). If null, a new agent is created.
+ /// An existing mock to reuse (for multi-turn tests). If null, a new mock is created.
+ /// Shared call index for multi-turn tests. If null, a new counter is created.
+ /// Shared captured inputs list for multi-turn tests. If null, a new list is created.
+ /// Optional initial chat history to pre-populate in .
+ /// Optional to pass to RunAsync.
+ ///
+ /// If provided, asserts the total number of service calls matches.
+ /// For multi-turn tests, pass null and verify after the final turn.
+ ///
+ ///
+ /// If provided, asserts that the persisted history matches these expected messages.
+ /// For multi-turn tests, pass null and verify after the final turn.
+ ///
+ /// A containing the response, session, agent, mock, and captured inputs.
+ public static async Task RunAsync(
+ List inputMessages,
+ List serviceCallExpectations,
+ ChatClientAgentOptions? agentOptions = null,
+ ChatClientAgentSession? existingSession = null,
+ ChatClientAgent? existingAgent = null,
+ Mock? existingMock = null,
+ Ref? callIndex = null,
+ List>? capturedInputs = null,
+ List? initialChatHistory = null,
+ AgentRunOptions? runOptions = null,
+ int? expectedServiceCallCount = null,
+ List? expectedHistory = null)
+ {
+ callIndex ??= new Ref(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(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;
+ }
+
+ ///
+ /// Asserts that the actual message list matches the expected message patterns structurally.
+ /// Checks message count, roles, optional text content, and optional content types.
+ ///
+ /// The actual messages to verify.
+ /// The expected message patterns.
+ public static void AssertMessagesMatch(List actual, List 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);
+ }
+ }
+ }
+
+ ///
+ /// Gets the persisted chat history from the agent's .
+ ///
+ /// The agent whose history provider to query.
+ /// The session to get history for.
+ /// The list of persisted messages, or an empty list if no provider is available.
+ public static List GetPersistedHistory(ChatClientAgent agent, AgentSession session)
+ {
+ var provider = agent.ChatHistoryProvider as InMemoryChatHistoryProvider;
+ return provider?.GetMessages(session) ?? [];
+ }
+
+ ///
+ /// Formats the contents of a message list as a diagnostic string for test failure messages.
+ ///
+ /// The messages to format.
+ /// A human-readable representation of the messages.
+ public static string FormatMessages(IEnumerable 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();
+ }
+
+ ///
+ /// A simple mutable reference wrapper for value types, allowing shared state across callbacks.
+ ///
+ public sealed class Ref(T value) where T : struct
+ {
+ public T Value { get; set; } = value;
+ }
+
+ ///
+ /// Asserts that a message's content collection contains the expected content types.
+ ///
+ private static void AssertContentTypes(IList 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))}]");
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs
index 2b3cfe43e8..7241ca763e 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs
@@ -379,18 +379,23 @@ public partial class ChatClientAgentTests
}
///
- /// 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.
///
[Fact]
- public async Task RunAsyncPassesNullChatOptionsWhenUsingRegularAgentRunOptionsAsync()
+ public async Task RunAsyncPassesChatOptionsWithNullConversationIdWhenUsingRegularAgentRunOptionsAsync()
{
// Arrange
+ ChatOptions? capturedOptions = null;
Mock mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny>(),
- null,
- It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
+ It.IsAny(),
+ It.IsAny()))
+ .Callback, 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>(),
- null,
- It.IsAny()),
- Times.Once);
+ // Assert — the inner client receives ChatOptions with null ConversationId (sentinel was stripped)
+ Assert.NotNull(capturedOptions);
+ Assert.Null(capturedOptions!.ConversationId);
}
///
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs
new file mode 100644
index 0000000000..6300942c9d
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs
@@ -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;
+
+///
+/// Contains unit tests that verify the end-to-end approval flow behavior of the
+/// class with ,
+/// ensuring that chat history is correctly persisted across multi-turn approval interactions.
+///
+public class ChatClientAgent_ApprovalsTests
+{
+ #region Per-Service-Call Persistence Approval Tests
+
+ ///
+ /// 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)].
+ ///
+ [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(0);
+ var capturedInputs = new List>();
+ var serviceExpectations = new List
+ {
+ // 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 { ["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()
+ .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().Any());
+ Assert.Contains(capturedInputs[1], m => m.Contents.OfType().Any());
+ }
+
+ #endregion
+
+ #region End-of-Run Persistence Approval Tests
+
+ ///
+ /// Verifies that with end-of-run persistence and an approval-required tool,
+ /// a two-turn approval flow persists the correct final history.
+ ///
+ [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(0);
+ var capturedInputs = new List>();
+ var serviceExpectations = new List
+ {
+ new(new ChatResponse([new(ChatRole.Assistant,
+ [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["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()
+ .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
+
+ ///
+ /// 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.
+ ///
+ [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(0);
+ var capturedInputs = new List>();
+ var serviceExpectations = new List
+ {
+ new(new ChatResponse([new(ChatRole.Assistant,
+ [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["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()
+ .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
+
+ ///
+ /// Verifies that when an approval is rejected, the rejection result is persisted in the history
+ /// and the model receives the rejection information.
+ ///
+ [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(0);
+ var capturedInputs = new List>();
+ var serviceExpectations = new List
+ {
+ // Turn 1: model requests function call
+ new(new ChatResponse([new(ChatRole.Assistant,
+ [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["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()
+ .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().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().Any(
+ frc => frc.Result?.ToString()?.Contains("rejected") == true));
+ }
+
+ #endregion
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs
index cc9b7acb19..3e54dbc06e 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs
@@ -500,4 +500,158 @@ public class ChatClientAgent_ChatHistoryManagementTests
}
#endregion
+
+ #region End-to-End Chat History Persistence Tests
+
+ ///
+ /// Verifies that with per-service-call persistence (default), a simple request/response
+ /// results in the correct chat history being persisted: [user, assistant].
+ ///
+ [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"),
+ ]);
+ }
+
+ ///
+ /// Verifies that with per-service-call persistence and a function calling loop,
+ /// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)].
+ ///
+ [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 { ["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"),
+ ]);
+ }
+
+ ///
+ /// Verifies that with end-of-run persistence, a simple request/response
+ /// results in the correct chat history being persisted: [user, assistant].
+ ///
+ [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"),
+ ]);
+ }
+
+ ///
+ /// Verifies that with end-of-run persistence and a function calling loop,
+ /// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)].
+ ///
+ [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 { ["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"),
+ ]);
+ }
+
+ ///
+ /// Verifies that when the service returns a ConversationId (service-stored history),
+ /// the session gets the ConversationId and no errors occur during the run.
+ ///
+ [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
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs
index 6dda0f0278..28d38ea36a 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs
@@ -176,10 +176,12 @@ public class ChatClientAgent_ChatOptionsMergingTests
}
///
- /// 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.
///
[Fact]
- public async Task ChatOptionsMergingReturnsNullWhenBothAgentAndRequestHaveNoneAsync()
+ public async Task ChatOptionsMergingReturnsChatOptionsWithNullConversationIdWhenBothAgentAndRequestHaveNoneAsync()
{
// Arrange
Mock mockService = new();
@@ -189,7 +191,7 @@ public class ChatClientAgent_ChatOptionsMergingTests
It.IsAny>(),
It.IsAny(),
It.IsAny()))
- .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) =>
+ .Callback, 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);
}
///
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs
index 459859224f..e7f91ab5d7 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs
@@ -763,4 +763,171 @@ public class ChatHistoryPersistingChatClientTests
await Task.CompletedTask;
}
+
+ ///
+ /// Verifies that when per-service-call persistence is active and no real conversation ID exists,
+ /// sets the
+ /// sentinel on the chat options and strips it before
+ /// forwarding to the inner client.
+ ///
+ [Fact]
+ public async Task RunAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync()
+ {
+ // Arrange
+ ChatOptions? capturedOptions = null;
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback, 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public async Task RunAsync_DoesNotSetSentinel_WhenEndOfRunPersistenceEnabledAsync()
+ {
+ // Arrange
+ ChatOptions? capturedOptions = null;
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback, 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);
+ }
+
+ ///
+ /// Verifies that the sentinel is NOT set when a real conversation ID is already present
+ /// on the session (indicating server-side history management).
+ ///
+ [Fact]
+ public async Task RunAsync_DoesNotSetSentinel_WhenRealConversationIdExistsAsync()
+ {
+ // Arrange
+ const string RealConversationId = "real-conv-123";
+ ChatOptions? capturedOptions = null;
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback, 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);
+ }
+
+ ///
+ /// Verifies that the sentinel is set and stripped correctly in the streaming path.
+ ///
+ [Fact]
+ public async Task RunStreamingAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync()
+ {
+ // Arrange
+ ChatOptions? capturedOptions = null;
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetStreamingResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback, 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public async Task RunAsync_SentinelDoesNotLeakToSession_WhenPerServiceCallPersistenceActiveAsync()
+ {
+ // Arrange
+ Mock mockService = new();
+ mockService.Setup(
+ s => s.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .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);
+ }
}
diff --git a/python/samples/demos/ag_ui_workflow_handoff/README.md b/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md
similarity index 93%
rename from python/samples/demos/ag_ui_workflow_handoff/README.md
rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md
index bd9a6b6a5f..51e1c9fc1c 100644
--- a/python/samples/demos/ag_ui_workflow_handoff/README.md
+++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md
@@ -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
```
diff --git a/python/samples/demos/ag_ui_workflow_handoff/backend/server.py b/python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py
similarity index 100%
rename from python/samples/demos/ag_ui_workflow_handoff/backend/server.py
rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py
diff --git a/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore
new file mode 100644
index 0000000000..16c69217c0
--- /dev/null
+++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore
@@ -0,0 +1,7 @@
+# dependencies
+/node_modules
+
+# build artifacts
+*.tsbuildinfo
+vite.config.js
+vite.config.d.ts
diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/index.html b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/index.html
similarity index 100%
rename from python/samples/demos/ag_ui_workflow_handoff/frontend/index.html
rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/index.html
diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/package-lock.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json
similarity index 100%
rename from python/samples/demos/ag_ui_workflow_handoff/frontend/package-lock.json
rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json
diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/package.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json
similarity index 100%
rename from python/samples/demos/ag_ui_workflow_handoff/frontend/package.json
rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json
diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/App.tsx b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/App.tsx
similarity index 100%
rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/App.tsx
rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/App.tsx
diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/main.tsx b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/main.tsx
similarity index 100%
rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/main.tsx
rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/main.tsx
diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/styles.css b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/styles.css
similarity index 100%
rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/styles.css
rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/styles.css
diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts
similarity index 100%
rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts
rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts
diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.json
similarity index 100%
rename from python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.json
rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.json
diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.node.json
similarity index 100%
rename from python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.json
rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.node.json
diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.ts b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/vite.config.ts
similarity index 100%
rename from python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.ts
rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/vite.config.ts
diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo b/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo
deleted file mode 100644
index 9c052ccd41..0000000000
--- a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo
+++ /dev/null
@@ -1 +0,0 @@
-{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/rollup/dist/parseast.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/types.d-agj9qkwt.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/vite/dist/node/runtime.d.ts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@types/babel__generator/index.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@types/babel__template/index.d.ts","./node_modules/@types/babel__traverse/index.d.ts","./node_modules/@types/babel__core/index.d.ts","./node_modules/@vitejs/plugin-react/dist/index.d.ts","./vite.config.ts"],"fileIdsList":[[78],[78,79,80,81,82],[78,80],[77,83],[69],[67,69],[58,66,67,68,70,72],[56],[59,64,69,72],[55,72],[59,60,63,64,65,72],[59,60,61,63,64,72],[56,57,58,59,60,64,65,66,68,69,70,72],[72],[54,56,57,58,59,60,61,63,64,65,66,67,68,69,70,71],[54,72],[59,61,62,64,65,72],[63,72],[64,65,69,72],[57,67],[47,76],[46,47],[47,48,49,50,51,52,53,73,74,75,76],[49,50,51,52],[49,50,51],[49],[50],[47],[77,84]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"151ff381ef9ff8da2da9b9663ebf657eac35c4c9a19183420c05728f31a6761d","impliedFormat":1},{"version":"ee70b8037ecdf0de6c04f35277f253663a536d7e38f1539d270e4e916d225a3f","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"282f98006ed7fa9bb2cd9bdbe2524595cfc4bcd58a0bb3232e4519f2138df811","impliedFormat":1},{"version":"6222e987b58abfe92597e1273ad7233626285bc2d78409d4a7b113d81a83496b","impliedFormat":1},{"version":"cbe726263ae9a7bf32352380f7e8ab66ee25b3457137e316929269c19e18a2be","impliedFormat":1},{"version":"8b96046bf5fb0a815cba6b0880d9f97b7f3a93cf187e8dcfe8e2792e97f38f87","impliedFormat":99},{"version":"bacf2c84cf448b2cd02c717ad46c3d7fd530e0c91282888c923ad64810a4d511","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"8885cf05f3e2abf117590bbb951dcf6359e3e5ac462af1c901cfd24c6a6472e2","impliedFormat":1},{"version":"333caa2bfff7f06017f114de738050dd99a765c7eb16571c6d25a38c0d5365dc","impliedFormat":1},{"version":"e61df3640a38d535fd4bc9f4a53aef17c296b58dc4b6394fd576b808dd2fe5e6","impliedFormat":1},{"version":"459920181700cec8cbdf2a5faca127f3f17fd8dd9d9e577ed3f5f3af5d12a2e4","impliedFormat":1},{"version":"4719c209b9c00b579553859407a7e5dcfaa1c472994bd62aa5dd3cc0757eb077","impliedFormat":1},{"version":"7ec359bbc29b69d4063fe7dad0baaf35f1856f914db16b3f4f6e3e1bca4099fa","impliedFormat":1},{"version":"70790a7f0040993ca66ab8a07a059a0f8256e7bb57d968ae945f696cbff4ac7a","impliedFormat":1},{"version":"d1b9a81e99a0050ca7f2d98d7eedc6cda768f0eb9fa90b602e7107433e64c04c","impliedFormat":1},{"version":"a022503e75d6953d0e82c2c564508a5c7f8556fad5d7f971372d2d40479e4034","impliedFormat":1},{"version":"b215c4f0096f108020f666ffcc1f072c81e9f2f95464e894a5d5f34c5ea2a8b1","impliedFormat":1},{"version":"644491cde678bd462bb922c1d0cfab8f17d626b195ccb7f008612dc31f445d2d","impliedFormat":1},{"version":"dfe54dab1fa4961a6bcfba68c4ca955f8b5bbeb5f2ab3c915aa7adaa2eabc03a","impliedFormat":1},{"version":"1251d53755b03cde02466064260bb88fd83c30006a46395b7d9167340bc59b73","impliedFormat":1},{"version":"47865c5e695a382a916b1eedda1b6523145426e48a2eae4647e96b3b5e52024f","impliedFormat":1},{"version":"4cdf27e29feae6c7826cdd5c91751cc35559125e8304f9e7aed8faef97dcf572","impliedFormat":1},{"version":"331b8f71bfae1df25d564f5ea9ee65a0d847c4a94baa45925b6f38c55c7039bf","impliedFormat":1},{"version":"2a771d907aebf9391ac1f50e4ad37952943515eeea0dcc7e78aa08f508294668","impliedFormat":1},{"version":"0146fd6262c3fd3da51cb0254bb6b9a4e42931eb2f56329edd4c199cb9aaf804","impliedFormat":1},{"version":"183f480885db5caa5a8acb833c2be04f98056bdcc5fb29e969ff86e07efe57ab","impliedFormat":99},{"version":"82e687ebd99518bc63ea04b0c3810fb6e50aa6942decd0ca6f7a56d9b9a212a6","impliedFormat":99},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"8f07f2b6514744ac96e51d7cb8518c0f4de319471237ea10cf688b8d0e9d0225","impliedFormat":1},{"version":"257b83faa134d971c738a6b9e4c47e59bb7b23274719d92197580dd662bfafc3","impliedFormat":99},{"version":"556ccd493ec36c7d7cb130d51be66e147b91cc1415be383d71da0f1e49f742a9","impliedFormat":1},{"version":"b6d03c9cfe2cf0ba4c673c209fcd7c46c815b2619fd2aad59fc4229aaef2ed43","impliedFormat":1},{"version":"95aba78013d782537cc5e23868e736bec5d377b918990e28ed56110e3ae8b958","impliedFormat":1},{"version":"670a76db379b27c8ff42f1ba927828a22862e2ab0b0908e38b671f0e912cc5ed","impliedFormat":1},{"version":"13b77ab19ef7aadd86a1e54f2f08ea23a6d74e102909e3c00d31f231ed040f62","impliedFormat":1},{"version":"069bebfee29864e3955378107e243508b163e77ab10de6a5ee03ae06939f0bb9","impliedFormat":1},{"version":"26e0ffceb2198feb1ef460d5d14111c69ad07d44c5a67fd4bfeb74c969aa9afb","impliedFormat":99},{"version":"2448a94bdacc4085b4fd26ccb7c3f323d04a220af29a24b61703903730b68984","signature":"4b96dd19fd2949d28ce80e913412b0026dc421e5bf6c31d87c7b5eb11b5753b4"}],"root":[85],"options":{"allowSyntheticDefaultImports":true,"composite":true,"module":99,"skipLibCheck":true,"target":7},"referencedMap":[[80,1],[83,2],[79,1],[81,3],[82,1],[84,4],[70,5],[68,6],[69,7],[57,8],[58,6],[65,9],[56,10],[61,11],[62,12],[67,13],[73,14],[72,15],[55,16],[63,17],[64,18],[59,19],[66,5],[60,20],[48,21],[47,22],[77,23],[74,24],[52,25],[50,26],[51,27],[76,28],[85,29]],"semanticDiagnosticsPerFile":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85],"latestChangedDtsFile":"./vite.config.d.ts","version":"5.9.3"}
\ No newline at end of file
diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo b/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo
deleted file mode 100644
index 68fc7fc564..0000000000
--- a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo
+++ /dev/null
@@ -1 +0,0 @@
-{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts b/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts
deleted file mode 100644
index 340562aff1..0000000000
--- a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-declare const _default: import("vite").UserConfig;
-export default _default;
diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js b/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js
deleted file mode 100644
index 96a3b3875f..0000000000
--- a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js
+++ /dev/null
@@ -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,
- },
-});