diff --git a/dotnet/src/Microsoft.Agents.AI/AgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI/AgentExtensions.cs index 2314d09273..439f5f4910 100644 --- a/dotnet/src/Microsoft.Agents.AI/AgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/AgentExtensions.cs @@ -89,6 +89,81 @@ async Task InvokeAgentAsync( return AIFunctionFactory.Create(InvokeAgentAsync, options); } + /// + /// Creates an that delegates its operations to the provided . + /// + /// The to be represented as an . + /// + /// Optional to use for every request made through the returned client. If not provided, + /// each request is made without a session and the caller is responsible for supplying the conversation history. + /// + /// + /// An that can be used anywhere the abstraction is consumed, + /// such as in a pipeline. + /// + /// is . + /// + /// + /// By default the returned client is stateless: no is used, so every call must supply the + /// full conversation history, just as when calling an directly. + /// + /// + /// If a is provided, the returned client is stateful, referencing both the + /// and the . Avoid using such a client concurrently in multiple + /// conversations or in requests where parallel calls may result in concurrent usage of the session, as that could + /// lead to undefined and unpredictable behavior. In particular, do not register a session-bound client as a shared + /// or singleton service that serves multiple users: concurrent use is unsupported, and every caller appends to and + /// reads from the same conversation, so history bleeds across them. Because a bound session already accumulates the + /// conversation history, callers should send only the new messages on each call rather than the full history, which + /// would otherwise be duplicated. + /// + /// + /// Any supplied to the returned client are passed to the agent as + /// . Agents that understand that type, such as , + /// honor those options; other agent implementations may ignore them. The exception is + /// , which is additionally copied to + /// and so may be honored by any agent implementation. + /// + /// + /// For agents that honor , this means a caller supplying + /// can add tools to those configured on the agent and append to its instructions; the + /// collections are unioned and the instructions concatenated rather than replaced. When requests originate from an + /// untrusted caller, do not pass caller-supplied through unfiltered. Follow the + /// default-closed pattern used by the Microsoft.Agents.AI.Hosting.OpenAI package, whose + /// RunOptionsFactory defaults to RejectRequestSettings and rejects caller-supplied settings unless the + /// host explicitly maps the ones it chooses to honor. + /// + /// + /// Background and continuation responses are not supported through the returned client. A continuation token + /// obtained from a is the underlying service's raw token rather than the agent's wrapped + /// token, so it does not round-trip: passing it back via causes + /// to reject it during token validation. + /// + /// + /// Some option combinations cause the underlying agent to throw an at run + /// time. With , requesting without a + /// bound throws, as does supplying a that + /// differs from the conversation id already held by a bound . + /// + /// + /// Calling this method on a returns an adapter over the full agent pipeline, including + /// its instructions, tools, chat history management, and any middleware. An unkeyed + /// request for returns the adapter itself, not the + /// agent's inner client. Keyed requests, and requests for other service types, are forwarded to + /// and may therefore return the inner client. + /// + /// + /// The returned client does not own the lifetime of the or the ; + /// disposing it does not dispose either of them. + /// + /// + public static IChatClient AsIChatClient(this AIAgent agent, AgentSession? session = null) + { + Throw.IfNull(agent); + + return new AIAgentChatClient(agent, session); + } + /// /// Removes characters from AI agent name that shouldn't be used in an AI function name. /// diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/AIAgentChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/AIAgentChatClient.cs new file mode 100644 index 0000000000..bf1a986e1c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/AIAgentChatClient.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides an implementation that delegates all of its operations to an . +/// +/// +/// +/// This adapter is the inverse of : rather than building an agent on top of a chat client, +/// it exposes an existing agent to any component that consumes the abstraction, such as +/// pipelines or helpers. +/// +/// +/// The adapter does not own the lifetime of the wrapped agent or session, so is a no-op. +/// +/// +internal sealed class AIAgentChatClient : IChatClient +{ + /// The agent to which all operations are delegated. + private readonly AIAgent _agent; + + /// The optional session to use for every request, or to operate statelessly. + private readonly AgentSession? _session; + + /// Lazily-created metadata synthesized from the agent's . + private ChatClientMetadata? _metadata; + + /// + /// Initializes a new instance of the class. + /// + /// The agent to which all operations are delegated. Must not be . + /// + /// The optional to use for every request. If , each request is + /// made without a session, and the caller is responsible for supplying the full conversation history. + /// + /// + /// is validated by , the only entry point + /// through which this internal type is constructed. + /// + public AIAgentChatClient(AIAgent agent, AgentSession? session) + { + this._agent = agent; + this._session = session; + } + + /// + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + var response = await this._agent.RunAsync(messages, this._session, ToAgentRunOptions(options), cancellationToken).ConfigureAwait(false); + + return response.AsChatResponse(); + } + + /// + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + // This method is deliberately not an iterator so that argument validation happens + // when the method is called rather than when the resulting sequence is enumerated. + _ = Throw.IfNull(messages); + + return this.GetStreamingResponseCoreAsync(messages, options, cancellationToken); + } + + /// + /// Streams the agent's response, converting each to a . + /// + /// The messages to send to the agent. + /// The chat options to apply to the run, if any. + /// The to monitor for cancellation requests. + /// An asynchronous sequence of instances. + /// + /// is annotated with so that a + /// token supplied by the consumer at enumeration time, via WithCancellation, also reaches the agent. Without + /// the annotation such a token would be silently dropped. + /// + private async IAsyncEnumerable GetStreamingResponseCoreAsync( + IEnumerable messages, + ChatOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var updates = this._agent.RunStreamingAsync(messages, this._session, ToAgentRunOptions(options), cancellationToken); + + await foreach (var update in updates.ConfigureAwait(false)) + { + yield return update.AsChatResponseUpdate(); + } + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + if (serviceKey is null && serviceType.IsInstanceOfType(this)) + { + return this; + } + + if (this._agent.GetService(serviceType, serviceKey) is { } service) + { + return service; + } + + if (serviceKey is null && serviceType == typeof(ChatClientMetadata)) + { + // A race here is benign: concurrent callers may each build an equivalent instance, and the + // reference assignment is atomic, so every caller still observes a fully-constructed object. + return this._metadata ??= new ChatClientMetadata(this._agent.GetService()?.ProviderName); + } + + return null; + } + + /// + /// + /// This adapter does not own the lifetime of the underlying or , + /// so disposing it has no effect and is safe to perform any number of times. + /// + public void Dispose() + { + // Intentionally a no-op: the adapter does not own the agent or session it wraps. + } + + /// + /// Converts into the agent run options understood by agents that support chat options. + /// + /// The chat options to convert, or if none were supplied. + /// + /// A carrying , or if + /// is . + /// + /// + /// + /// is additionally surfaced on the base + /// so that agents which do not understand can still honor it. + /// + /// + /// It is deliberately the only option copied to the base type. is the + /// single member whose counterpart any agent implementation can meaningfully act on. + /// and + /// are not mapped: background responses require a session and continuation tokens that do not round-trip through + /// the abstraction, and additional properties carry agent-specific semantics that a + /// caller supplying is not expressing. + /// + /// + private static ChatClientAgentRunOptions? ToAgentRunOptions(ChatOptions? options) => + options is null ? null : new ChatClientAgentRunOptions(options) { ResponseFormat = options.ResponseFormat }; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIAgentChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIAgentChatClientTests.cs new file mode 100644 index 0000000000..98aaeb2e90 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIAgentChatClientTests.cs @@ -0,0 +1,700 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the method and the +/// adapter it returns. +/// +public partial class AIAgentChatClientTests +{ + [Fact] + public void AsIChatClient_WithNullAgent_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => + AIAgentExtensions.AsIChatClient(null!)); + + Assert.Equal("agent", exception.ParamName); + } + + [Fact] + public void AsIChatClient_WithValidAgent_ReturnsChatClient() + { + // Arrange + var mockAgent = new Mock(); + + // Act + using var chatClient = mockAgent.Object.AsIChatClient(); + + // Assert + Assert.NotNull(chatClient); + Assert.IsAssignableFrom(chatClient); + } + + [Fact] + public async Task GetResponseAsync_WithNullMessages_ThrowsArgumentNullExceptionAsync() + { + // Arrange + var mockAgent = new Mock(); + using var chatClient = mockAgent.Object.AsIChatClient(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + chatClient.GetResponseAsync(null!)); + + Assert.Equal("messages", exception.ParamName); + } + + [Fact] + public void GetStreamingResponseAsync_WithNullMessages_ThrowsSynchronously() + { + // Arrange + var mockAgent = new Mock(); + using var chatClient = mockAgent.Object.AsIChatClient(); + + // Act & Assert + // The exception must be raised by the call itself, before any enumeration takes place, + // which would not be the case if the method were implemented as an iterator. + var exception = Assert.Throws( + (Action)(() => chatClient.GetStreamingResponseAsync(null!))); + + Assert.Equal("messages", exception.ParamName); + } + + [Fact] + public async Task GetResponseAsync_WithoutChatOptions_ForwardsMessagesAndNullSessionAndOptionsAsync() + { + // Arrange + IEnumerable? capturedMessages = null; + AgentSession? capturedSession = null; + AgentRunOptions? capturedOptions = null; + CancellationToken capturedCancellationToken = default; + var invocationCount = 0; + + var agentResponse = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Hello from the agent.")); + var agent = new TestAIAgent + { + RunAsyncFunc = (messages, session, options, cancellationToken) => + { + invocationCount++; + capturedMessages = messages; + capturedSession = session; + capturedOptions = options; + capturedCancellationToken = cancellationToken; + return Task.FromResult(agentResponse); + } + }; + + using var chatClient = agent.AsIChatClient(); + using var cancellationTokenSource = new CancellationTokenSource(); + List inputMessages = [new(ChatRole.User, "Hi")]; + + // Act + var response = await chatClient.GetResponseAsync(inputMessages, cancellationToken: cancellationTokenSource.Token); + + // Assert + Assert.Equal(1, invocationCount); + Assert.Same(inputMessages, capturedMessages); + Assert.Null(capturedSession); + Assert.Null(capturedOptions); + Assert.Equal(cancellationTokenSource.Token, capturedCancellationToken); + + Assert.Equal("Hello from the agent.", response.Text); + Assert.Same(agentResponse.Messages, response.Messages); + } + + [Fact] + public async Task GetResponseAsync_WithEmptyMessages_ForwardsEmptySequenceAsync() + { + // Arrange + IEnumerable? capturedMessages = null; + + var agent = new TestAIAgent + { + RunAsyncFunc = (messages, session, options, cancellationToken) => + { + capturedMessages = messages; + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "empty input accepted"))); + } + }; + + using var chatClient = agent.AsIChatClient(); + List inputMessages = []; + + // Act + var response = await chatClient.GetResponseAsync(inputMessages); + + // Assert + // An empty sequence is valid input and must reach the agent unchanged; only null is rejected. + Assert.Same(inputMessages, capturedMessages); + Assert.Empty(capturedMessages!); + Assert.Equal("empty input accepted", response.Text); + } + + [Fact] + public async Task GetResponseAsync_WithChatOptions_ForwardsChatClientAgentRunOptionsAsync() + { + // Arrange + AgentRunOptions? capturedOptions = null; + + var agent = new TestAIAgent + { + RunAsyncFunc = (messages, session, options, cancellationToken) => + { + capturedOptions = options; + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "ok"))); + } + }; + + using var chatClient = agent.AsIChatClient(); + var chatOptions = new ChatOptions + { + Temperature = 0.5f, + ResponseFormat = ChatResponseFormat.Json + }; + + // Act + await chatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "Hi")], chatOptions); + + // Assert + var agentRunOptions = Assert.IsType(capturedOptions); + Assert.Same(chatOptions, agentRunOptions.ChatOptions); + Assert.Same(chatOptions.ResponseFormat, agentRunOptions.ResponseFormat); + } + + [Fact] + public async Task GetResponseAsync_WithBoundSession_ForwardsSessionAsync() + { + // Arrange + AgentSession? capturedSession = null; + var boundSession = new ChatClientAgentSession(); + + var agent = new TestAIAgent + { + RunAsyncFunc = (messages, session, options, cancellationToken) => + { + capturedSession = session; + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "ok"))); + } + }; + + using var chatClient = agent.AsIChatClient(boundSession); + + // Act + await chatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "Hi")]); + + // Assert + Assert.Same(boundSession, capturedSession); + } + + [Fact] + public async Task GetResponseAsync_WithChatResponseRawRepresentation_ReturnsSameInstanceAsync() + { + // Arrange + var innerChatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello")) + { + ConversationId = "conversation-42", + ResponseId = "response-42" + }; + + var agentResponse = new AgentResponse(innerChatResponse); + + var agent = new TestAIAgent + { + RunAsyncFunc = (messages, session, options, cancellationToken) => Task.FromResult(agentResponse) + }; + + using var chatClient = agent.AsIChatClient(); + + // Act + var response = await chatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "Hi")]); + + // Assert + Assert.Same(innerChatResponse, response); + Assert.Equal("conversation-42", response.ConversationId); + Assert.Equal("response-42", response.ResponseId); + } + + [Fact] + public async Task GetStreamingResponseAsync_ConvertsUpdatesAndPropagatesCancellationTokenAsync() + { + // Arrange + CancellationToken capturedCancellationToken = default; + IEnumerable? capturedMessages = null; + AgentSession? capturedSession = null; + AgentRunOptions? capturedOptions = null; + + List updates = + [ + new(ChatRole.Assistant, "Hello, ") { MessageId = "message-1" }, + new(ChatRole.Assistant, "world!") { MessageId = "message-1" } + ]; + + var boundSession = new ChatClientAgentSession(); + var agent = new TestAIAgent + { + RunStreamingAsyncFunc = (messages, session, options, cancellationToken) => + { + capturedMessages = messages; + capturedSession = session; + capturedOptions = options; + capturedCancellationToken = cancellationToken; + return ToAsyncEnumerableAsync(updates, cancellationToken); + } + }; + + using var chatClient = agent.AsIChatClient(boundSession); + using var cancellationTokenSource = new CancellationTokenSource(); + List inputMessages = [new(ChatRole.User, "Hi")]; + var chatOptions = new ChatOptions(); + + // Act + List receivedUpdates = []; + await foreach (var update in chatClient.GetStreamingResponseAsync(inputMessages, chatOptions, cancellationTokenSource.Token)) + { + receivedUpdates.Add(update); + } + + // Assert + Assert.Same(inputMessages, capturedMessages); + Assert.Same(boundSession, capturedSession); + Assert.Same(chatOptions, Assert.IsType(capturedOptions).ChatOptions); + Assert.Equal(cancellationTokenSource.Token, capturedCancellationToken); + + Assert.Equal(2, receivedUpdates.Count); + Assert.Equal("Hello, ", receivedUpdates[0].Text); + Assert.Equal(ChatRole.Assistant, receivedUpdates[0].Role); + Assert.Equal("message-1", receivedUpdates[0].MessageId); + Assert.Equal("world!", receivedUpdates[1].Text); + Assert.Equal(ChatRole.Assistant, receivedUpdates[1].Role); + Assert.Equal("message-1", receivedUpdates[1].MessageId); + } + + [Fact] + public async Task GetStreamingResponseAsync_WithCancellationSuppliedAtEnumeration_ForwardsTokenToAgentAsync() + { + // Arrange + CancellationToken capturedCancellationToken = default; + + var agent = new TestAIAgent + { + RunStreamingAsyncFunc = (messages, session, options, cancellationToken) => + { + capturedCancellationToken = cancellationToken; + return ToAsyncEnumerableAsync([new(ChatRole.Assistant, "chunk")], cancellationToken); + } + }; + + using var chatClient = agent.AsIChatClient(); + using var cancellationTokenSource = new CancellationTokenSource(); + + // Act + // No token is supplied to the call itself; it is attached at enumeration time instead, which only + // reaches the agent if the streaming iterator honors [EnumeratorCancellation]. + var updates = chatClient.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "Hi")]); + + await foreach (var _ in updates.WithCancellation(cancellationTokenSource.Token)) + { + // Enumerate to completion. + } + + // Assert + Assert.Equal(cancellationTokenSource.Token, capturedCancellationToken); + } + + [Fact] + public async Task GetStreamingResponseAsync_WithChatResponseUpdateRawRepresentation_YieldsSameInstanceAsync() + { + // Arrange + var innerUpdate = new ChatResponseUpdate(ChatRole.Assistant, "raw chunk") { MessageId = "message-7" }; + var agentUpdate = new AgentResponseUpdate(ChatRole.Assistant, "converted chunk") + { + RawRepresentation = innerUpdate + }; + + var agent = new TestAIAgent + { + RunStreamingAsyncFunc = (messages, session, options, cancellationToken) => + ToAsyncEnumerableAsync([agentUpdate], cancellationToken) + }; + + using var chatClient = agent.AsIChatClient(); + + // Act + List receivedUpdates = []; + await foreach (var update in chatClient.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "Hi")])) + { + receivedUpdates.Add(update); + } + + // Assert + // Mirrors the response-level identity guarantee: an update that already carries a ChatResponseUpdate + // raw representation is passed through rather than re-wrapped. + var received = Assert.Single(receivedUpdates); + Assert.Same(innerUpdate, received); + Assert.Equal("raw chunk", received.Text); + } + + [Fact] + public async Task GetStreamingResponseAsync_WithChatOptions_ForwardsChatClientAgentRunOptionsAsync() + { + // Arrange + AgentRunOptions? capturedOptions = null; + + var agent = new TestAIAgent + { + RunStreamingAsyncFunc = (messages, session, options, cancellationToken) => + { + capturedOptions = options; + return ToAsyncEnumerableAsync([new(ChatRole.Assistant, "chunk")], cancellationToken); + } + }; + + using var chatClient = agent.AsIChatClient(); + var chatOptions = new ChatOptions { ResponseFormat = ChatResponseFormat.Json }; + + // Act + await foreach (var _ in chatClient.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "Hi")], chatOptions)) + { + // Enumerate to completion. + } + + // Assert + var agentRunOptions = Assert.IsType(capturedOptions); + Assert.Same(chatOptions, agentRunOptions.ChatOptions); + Assert.Same(chatOptions.ResponseFormat, agentRunOptions.ResponseFormat); + } + + [Fact] + public void GetService_WithNullServiceType_ThrowsArgumentNullException() + { + // Arrange + var mockAgent = new Mock(); + using var chatClient = mockAgent.Object.AsIChatClient(); + + // Act & Assert + var exception = Assert.Throws( + (Action)(() => chatClient.GetService(null!))); + + Assert.Equal("serviceType", exception.ParamName); + } + + [Fact] + public void GetService_WithChatClientType_ReturnsAdapter() + { + // Arrange + var agent = new TestAIAgent(); + using var chatClient = agent.AsIChatClient(); + + // Act + var service = chatClient.GetService(typeof(IChatClient)); + + // Assert + Assert.Same(chatClient, service); + } + + [Fact] + public void GetService_WithChatClientTypeOverChatClientAgent_ReturnsAdapterNotInnerClient() + { + // Arrange + // ChatClientAgent.GetService(typeof(IChatClient)) returns its INNER chat client, so this agent is the + // only one that can distinguish the adapter's self-check from the forward-to-agent branch. Backing the + // adapter with a TestAIAgent would let those two branches be swapped without any test failing, while + // silently unwrapping the agent pipeline. + var mockChatClient = new Mock(); + var agent = new ChatClientAgent(mockChatClient.Object); + + using var chatClient = agent.AsIChatClient(); + + // Act + var service = chatClient.GetService(typeof(IChatClient)); + + // Assert + Assert.Same(chatClient, service); + Assert.NotSame(mockChatClient.Object, service); + + // Sanity check that forwarding first would have produced something else: the agent returns its own + // (decorated) inner chat client, never the adapter. This is what makes the branch order load-bearing. + var innerClientFromAgent = agent.GetService(typeof(IChatClient)); + Assert.NotNull(innerClientFromAgent); + Assert.NotSame(chatClient, innerClientFromAgent); + } + + [Fact] + public void GetService_WithAgentType_ReturnsAgent() + { + // Arrange + var agent = new TestAIAgent(); + using var chatClient = agent.AsIChatClient(); + + // Act + var service = chatClient.GetService(typeof(AIAgent)); + + // Assert + Assert.Same(agent, service); + } + + [Fact] + public void GetService_WithKeyedOrUnknownRequest_ForwardsToAgent() + { + // Arrange + List<(Type ServiceType, object? ServiceKey)> capturedRequests = []; + var keyedService = new object(); + + var agent = new TestAIAgent + { + GetServiceFunc = (serviceType, serviceKey) => + { + capturedRequests.Add((serviceType, serviceKey)); + return serviceKey is "key" ? keyedService : null; + } + }; + + using var chatClient = agent.AsIChatClient(); + + // Act + var keyed = chatClient.GetService(typeof(IChatClient), "key"); + var unknown = chatClient.GetService(typeof(Uri)); + + // Assert + Assert.Same(keyedService, keyed); + Assert.Null(unknown); + Assert.Equal(2, capturedRequests.Count); + Assert.Equal((typeof(IChatClient), "key"), capturedRequests[0]); + Assert.Equal((typeof(Uri), (object?)null), capturedRequests[1]); + } + + [Fact] + public void GetService_WithChatClientMetadata_SynthesizesFromAgentMetadata() + { + // Arrange + var agent = new TestAIAgent + { + GetServiceFunc = (serviceType, serviceKey) => + serviceType == typeof(AIAgentMetadata) ? new AIAgentMetadata("test-provider") : null + }; + + using var chatClient = agent.AsIChatClient(); + + // Act + var metadata = chatClient.GetService(typeof(ChatClientMetadata)) as ChatClientMetadata; + var secondMetadata = chatClient.GetService(typeof(ChatClientMetadata)) as ChatClientMetadata; + + // Assert + Assert.NotNull(metadata); + Assert.Equal("test-provider", metadata!.ProviderName); + Assert.Same(metadata, secondMetadata); + } + + [Fact] + public void GetService_WithChatClientMetadataProvidedByAgent_ReturnsAgentInstance() + { + // Arrange + var agentProvidedMetadata = new ChatClientMetadata("agent-provided"); + var agent = new TestAIAgent + { + GetServiceFunc = (serviceType, serviceKey) => + serviceType == typeof(ChatClientMetadata) ? agentProvidedMetadata : + serviceType == typeof(AIAgentMetadata) ? new AIAgentMetadata("synthesized") : + null + }; + + using var chatClient = agent.AsIChatClient(); + + // Act + var metadata = chatClient.GetService(typeof(ChatClientMetadata)); + + // Assert + Assert.Same(agentProvidedMetadata, metadata); + } + + [Fact] + public async Task Dispose_IsNoOpAndIdempotentAsync() + { + // Arrange + var agent = new TestAIAgent + { + RunAsyncFunc = (messages, session, options, cancellationToken) => + Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "still alive"))) + }; + + var chatClient = agent.AsIChatClient(); + + // Act + chatClient.Dispose(); + chatClient.Dispose(); + + // Assert + // Disposal does not own the agent, so the adapter remains usable and the agent is untouched. + var response = await chatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "Hi")]); + Assert.Equal("still alive", response.Text); + } + + [Fact] + public async Task GetResponseAsync_OverChatClientAgent_AppliesAgentInstructionsAndToolsAsync() + { + // Arrange + ChatOptions? capturedChatOptions = null; + + var mockChatClient = new Mock(); + mockChatClient + .Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, options, _) => capturedChatOptions = options) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response from the service.")) + { + ConversationId = "conversation-out" + }); + + var agentTool = AIFunctionFactory.Create(() => "agent tool result", "AgentTool"); + var requestTool = AIFunctionFactory.Create(() => "request tool result", "RequestTool"); + + var agent = new ChatClientAgent( + mockChatClient.Object, + instructions: "agent instructions", + tools: [agentTool]); + + using var chatClient = agent.AsIChatClient(); + + // Act + var response = await chatClient.GetResponseAsync( + [new ChatMessage(ChatRole.User, "Hi")], + new ChatOptions + { + Instructions = "request instructions", + ConversationId = "conversation-in", + Tools = [requestTool] + }); + + // Assert + Assert.NotNull(capturedChatOptions); + Assert.Equal("agent instructions\nrequest instructions", capturedChatOptions!.Instructions); + Assert.Equal("conversation-in", capturedChatOptions.ConversationId); + Assert.NotNull(capturedChatOptions.Tools); + Assert.Contains(capturedChatOptions.Tools!, t => t.Name == "AgentTool"); + Assert.Contains(capturedChatOptions.Tools!, t => t.Name == "RequestTool"); + + Assert.Equal("Response from the service.", response.Text); + Assert.Equal("conversation-out", response.ConversationId); + } + + [Fact] + public async Task GetStreamingResponseAsync_OverChatClientAgent_StreamsThroughAgentPipelineAsync() + { + // Arrange + ChatOptions? capturedChatOptions = null; + + List serviceUpdates = + [ + new(ChatRole.Assistant, "Streamed ") { MessageId = "message-1" }, + new(ChatRole.Assistant, "from the service.") { MessageId = "message-1" } + ]; + + var mockChatClient = new Mock(); + mockChatClient + .Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns, ChatOptions?, CancellationToken>((_, options, ct) => + { + capturedChatOptions = options; + return ToAsyncEnumerableAsync(serviceUpdates, ct); + }); + + var agent = new ChatClientAgent(mockChatClient.Object, instructions: "agent instructions"); + + using var chatClient = agent.AsIChatClient(); + + // Act + List receivedUpdates = []; + await foreach (var update in chatClient.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "Hi")])) + { + receivedUpdates.Add(update); + } + + // Assert + Assert.NotNull(capturedChatOptions); + Assert.Equal("agent instructions", capturedChatOptions!.Instructions); + + Assert.Equal(2, receivedUpdates.Count); + Assert.Equal("Streamed from the service.", string.Concat(receivedUpdates.Select(u => u.Text))); + Assert.All(receivedUpdates, u => Assert.Equal(ChatRole.Assistant, u.Role)); + } + + [Fact] + public async Task GetResponseAsync_OverChatClientAgent_SupportsStructuredOutputAsync() + { + // Arrange + ChatResponseFormat? capturedResponseFormat = null; + var expectedResult = new WeatherReport { City = "Seattle", TemperatureCelsius = 12 }; + + var mockChatClient = new Mock(); + mockChatClient + .Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, options, _) => capturedResponseFormat = options?.ResponseFormat) + .ReturnsAsync(() => new ChatResponse(new ChatMessage( + ChatRole.Assistant, + JsonSerializer.Serialize(expectedResult, WeatherJsonContext.Default.WeatherReport)))); + + var agent = new ChatClientAgent(mockChatClient.Object); + + using var chatClient = agent.AsIChatClient(); + + // Act + var response = await chatClient.GetResponseAsync( + [new ChatMessage(ChatRole.User, "What is the weather in Seattle?")], + WeatherJsonContext.Default.Options); + + // Assert + Assert.IsType(capturedResponseFormat); + Assert.Equal(expectedResult.City, response.Result.City); + Assert.Equal(expectedResult.TemperatureCelsius, response.Result.TemperatureCelsius); + } + + /// + /// Wraps a synchronous sequence in an asynchronous sequence for use by streaming tests. + /// + private static async IAsyncEnumerable ToAsyncEnumerableAsync( + IEnumerable items, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + yield return item; + } + } + + /// + /// A simple structured-output payload used by the end-to-end structured output test. + /// + private sealed class WeatherReport + { + public string? City { get; set; } + + public int TemperatureCelsius { get; set; } + } + + [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] + [JsonSerializable(typeof(WeatherReport))] + private sealed partial class WeatherJsonContext : JsonSerializerContext; +}