From 9691c9c27100bb9de5ff721d78dc9e7473f6060e Mon Sep 17 00:00:00 2001 From: AkiKurisu <2683987717@qq.com> Date: Fri, 27 Mar 2026 00:13:00 +0800 Subject: [PATCH 1/5] .NET: Fix role assignment in ChatMessage construction (#4290) * Use actual message role when creating ChatMessage Replace hard-coded ChatRole.User with a ChatRole constructed from the message's Role. The change ensures ToChatMessage and FunctionMessage use the original role (new ChatRole(this.Role)) for both text and contents branches, fixing incorrect role assignment when constructing ChatMessage instances. * Update changes * Fix formatting in ToChatMessage tests --------- Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> --- .../Models/ChatCompletionRequestMessage.cs | 10 +- ...pletionRequestMessageToChatMessageTests.cs | 115 ++++++++++++++++++ 2 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ChatCompletionRequestMessageToChatMessageTests.cs 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/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."); + } +} From 3b8b56e6ea86a6d351248826f2097ac0f5bcecdb Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:45:01 +0000 Subject: [PATCH 2/5] Samples fix (#4932) --- .../Program.cs | 20 ++++++++++++++---- .../Agent_Step08_UsingImages.csproj | 6 ++++++ .../Assets/walkway.jpg | Bin 0 -> 37970 bytes .../Agent_Step08_UsingImages/Program.cs | 2 +- .../Program.cs | 2 +- 5 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Assets/walkway.jpg 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 0000000000000000000000000000000000000000..13ef1e184087e7ac9fdb086d523a2bac486ce3f4 GIT binary patch literal 37970 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 literal 0 HcmV?d00001 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(); From 63dee91a5fa08eab6e6c92009a613d117ba0b3aa Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Thu, 26 Mar 2026 10:33:22 -0700 Subject: [PATCH 3/5] .NET: Improve observability sample (#4917) * Improve .Net observability sample * Update dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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(); From 3585581c7a6415db08832736b1d7a09cae6fcf22 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:45:46 +0000 Subject: [PATCH 4/5] .NET: Fix bug with per-service-call persistence and approvals (#4933) * Fix bug with per-service-call persistence and approvals * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ChatClient/ChatClientAgent.cs | 27 ++ .../ChatHistoryPersistingChatClient.cs | 38 +++ .../ChatClient/ChatClientAgentTestHelper.cs | 259 +++++++++++++++ .../ChatClient/ChatClientAgentTests.cs | 23 +- .../ChatClientAgent_ApprovalsTests.cs | 306 ++++++++++++++++++ ...tClientAgent_ChatHistoryManagementTests.cs | 154 +++++++++ ...ChatClientAgent_ChatOptionsMergingTests.cs | 13 +- .../ChatHistoryPersistingChatClientTests.cs | 167 ++++++++++ 8 files changed, 971 insertions(+), 16 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs 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.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); + } } From 9bfa593ae7bea160aef6885962611590423f59a9 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 27 Mar 2026 02:52:07 +0900 Subject: [PATCH 5/5] Python: Move ag_ui_workflow_handoff demo from demos/ to 05-end-to-end/ (#4900) * Move ag_ui_workflow_handoff demo to 05-end-to-end (#4895) Move the AG-UI workflow handoff demo from python/samples/demos/ to python/samples/05-end-to-end/ to follow the current folder structure convention. Update README paths accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix review feedback: remove build artifacts, fix README paths (#4895) - Add .gitignore to frontend/ to exclude *.tsbuildinfo, vite.config.js, and vite.config.d.ts build artifacts from version control - Remove the 4 tracked build artifact files from the tree - Fix step 2 cd path in README to be relative after 'cd python' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clarify working directory context in README Step 2 (#4895) Step 2 uses a python/-relative path (samples/...) which assumes the user is still in the python/ directory from Step 1. Add a brief note making this explicit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ag_ui_workflow_handoff/README.md | 8 +++++--- .../ag_ui_workflow_handoff/backend/server.py | 0 .../ag_ui_workflow_handoff/frontend/.gitignore | 7 +++++++ .../ag_ui_workflow_handoff/frontend/index.html | 0 .../ag_ui_workflow_handoff/frontend/package-lock.json | 0 .../ag_ui_workflow_handoff/frontend/package.json | 0 .../ag_ui_workflow_handoff/frontend/src/App.tsx | 0 .../ag_ui_workflow_handoff/frontend/src/main.tsx | 0 .../ag_ui_workflow_handoff/frontend/src/styles.css | 0 .../ag_ui_workflow_handoff/frontend/src/vite-env.d.ts | 0 .../ag_ui_workflow_handoff/frontend/tsconfig.json | 0 .../frontend/tsconfig.node.json | 0 .../ag_ui_workflow_handoff/frontend/vite.config.ts | 0 .../frontend/tsconfig.node.tsbuildinfo | 1 - .../frontend/tsconfig.tsbuildinfo | 1 - .../ag_ui_workflow_handoff/frontend/vite.config.d.ts | 2 -- .../ag_ui_workflow_handoff/frontend/vite.config.js | 11 ----------- 17 files changed, 12 insertions(+), 18 deletions(-) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/README.md (93%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/backend/server.py (100%) create mode 100644 python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/index.html (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/package-lock.json (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/package.json (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/src/App.tsx (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/src/main.tsx (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/src/styles.css (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/tsconfig.json (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/tsconfig.node.json (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/vite.config.ts (100%) delete mode 100644 python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo delete mode 100644 python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo delete mode 100644 python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts delete mode 100644 python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js 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, - }, -});