diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs index fd1c2fd7f5..cffde717e4 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs @@ -3,10 +3,12 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI; @@ -32,6 +34,13 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable private readonly OpenTelemetryChatClient _otelClient; /// The provider name extracted from . private readonly string? _providerName; + /// The resolved source name for telemetry. Always non-empty; defaults to . + private readonly string _sourceName; + /// + /// Indicates whether the underlying of a inner agent + /// should be automatically wrapped with on each invocation. + /// + private readonly bool _autoWireChatClient; /// Initializes a new instance of the class. /// The underlying to be augmented with telemetry capabilities. @@ -44,13 +53,44 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable /// The constructor automatically extracts provider metadata from the inner agent and configures /// telemetry collection according to OpenTelemetry semantic conventions for AI systems. /// - public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName = null) : base(innerAgent) + public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName = null) +#pragma warning disable MAAI001 // Auto-wiring is the new default; the experimental opt-out lives on the 3-arg overload. + : this(innerAgent, sourceName, autoWireChatClient: true) +#pragma warning restore MAAI001 + { + } + + /// Initializes a new instance of the class. + /// The underlying to be augmented with telemetry capabilities. + /// + /// An optional source name that will be used to identify telemetry data from this agent. + /// If not provided, a default source name will be used for telemetry identification. + /// + /// + /// When and the inner agent is a , the underlying + /// is automatically wrapped with for each invocation + /// so that chat-level telemetry flows alongside agent-level telemetry. If the underlying chat client is already + /// instrumented, no additional wrapping is applied. Set to to opt-out of this behavior. + /// + /// is . + /// + /// The constructor automatically extracts provider metadata from the inner agent and configures + /// telemetry collection according to OpenTelemetry semantic conventions for AI systems. + /// + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] + public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName, bool autoWireChatClient) : base(innerAgent) { this._providerName = innerAgent.GetService()?.ProviderName; + // Resolve once so the outer OpenTelemetryChatClient and the auto-wired inner + // OpenTelemetryChatClient always emit spans under the same ActivitySource, even when + // the caller passes "" or whitespace (which neither client should treat as a real source). + this._sourceName = string.IsNullOrWhiteSpace(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!; + this._autoWireChatClient = autoWireChatClient; + this._otelClient = new OpenTelemetryChatClient( new ForwardingChatClient(this), - sourceName: string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!); + sourceName: this._sourceName); } /// @@ -163,6 +203,85 @@ public ForwardedOptions(AgentRunOptions? options, AgentSession? session, Activit public Activity? CurrentActivity { get; } } + /// + /// If auto-wiring is enabled and the inner agent is a whose underlying + /// is not already instrumented with , returns a + /// new with a + /// that wraps the chat client with . When is a + /// plain (the base type, not ), the base + /// properties are copied onto the new so high-level callers that pass + /// the abstract still benefit from auto-wiring and propagate their settings to + /// the inner agent. Otherwise, returns unchanged. + /// + private AgentRunOptions? GetRunOptionsWithChatClientWiring(AgentRunOptions? options) + { + if (!this._autoWireChatClient) + { + return options; + } + + // The auto-wiring only applies when a ChatClientAgent is reachable from the inner agent. Otherwise, no-op. + // Use GetService rather than a type check so wrapping agents that expose a nested ChatClientAgent are supported. + var chatClientAgent = this.InnerAgent.GetService(); + if (chatClientAgent is null) + { + return options; + } + + // Respect ChatClientAgentOptions.UseProvidedChatClientAsIs: don't decorate the chat client when the user opted out. + if (chatClientAgent.GetService()?.UseProvidedChatClientAsIs is true) + { + return options; + } + + // Capture the underlying IChatClient and check whether it is already instrumented. + var chatClient = chatClientAgent.GetService(); + if (chatClient is null || chatClient.GetService(typeof(OpenTelemetryChatClient)) is not null) + { + return options; + } + + string sourceName = this._sourceName; + static IChatClient WrapIfNeeded(IChatClient cc, string sourceName) => + cc.GetService(typeof(OpenTelemetryChatClient)) is not null + ? cc + : cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(); + + if (options is ChatClientAgentRunOptions ccOptions) + { + // Don't mutate the caller's options; clone and chain any caller-provided factory. + // If the user factory already returns an OpenTelemetry-instrumented client, don't double-wrap. + var clone = (ChatClientAgentRunOptions)ccOptions.Clone(); + var userFactory = clone.ChatClientFactory; + clone.ChatClientFactory = cc => WrapIfNeeded(userFactory is null ? cc : userFactory(cc), sourceName); + return clone; + } + + // For a plain AgentRunOptions (or null), create a ChatClientAgentRunOptions and preserve + // any base AgentRunOptions properties from the caller so they reach the inner agent. + var newOptions = new ChatClientAgentRunOptions + { + ChatClientFactory = cc => WrapIfNeeded(cc, sourceName), + }; + + if (options is not null) + { + CopyBaseAgentRunOptions(options, newOptions); + } + + return newOptions; + } + +#pragma warning disable MEAI001 // ContinuationToken is experimental; copy it through to preserve caller-provided value. + private static void CopyBaseAgentRunOptions(AgentRunOptions source, AgentRunOptions target) + { + target.ContinuationToken = source.ContinuationToken; + target.AllowBackgroundResponses = source.AllowBackgroundResponses; + target.AdditionalProperties = source.AdditionalProperties?.Clone(); + target.ResponseFormat = source.ResponseFormat; + } +#pragma warning restore MEAI001 + /// The stub used to delegate from the into the inner . /// private sealed class ForwardingChatClient(OpenTelemetryAgent parentAgent) : IChatClient @@ -175,8 +294,11 @@ public async Task GetResponseAsync( // Update the current activity to reflect the agent invocation. parentAgent.UpdateCurrentActivity(fo?.CurrentActivity); + // If enabled, wire the underlying chat client with OpenTelemetryChatClient via ChatClientFactory. + var runOptions = parentAgent.GetRunOptionsWithChatClientWiring(fo?.Options); + // Invoke the inner agent. - var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Session, fo?.Options, cancellationToken).ConfigureAwait(false); + var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Session, runOptions, cancellationToken).ConfigureAwait(false); // Wrap the response in a ChatResponse so we can pass it back through OpenTelemetryChatClient. return response.AsChatResponse(); @@ -190,8 +312,11 @@ public async IAsyncEnumerable GetStreamingResponseAsync( // Update the current activity to reflect the agent invocation. parentAgent.UpdateCurrentActivity(fo?.CurrentActivity); + // If enabled, wire the underlying chat client with OpenTelemetryChatClient via ChatClientFactory. + var runOptions = parentAgent.GetRunOptionsWithChatClientWiring(fo?.Options); + // Invoke the inner agent. - await foreach (var update in parentAgent.InnerAgent.RunStreamingAsync(messages, fo?.Session, fo?.Options, cancellationToken).ConfigureAwait(false)) + await foreach (var update in parentAgent.InnerAgent.RunStreamingAsync(messages, fo?.Session, runOptions, cancellationToken).ConfigureAwait(false)) { // Wrap the response updates in ChatResponseUpdates so we can pass them back through OpenTelemetryChatClient. yield return update.AsChatResponseUpdate(); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs index b8ddd2feaa..1f04d09ba3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs @@ -627,4 +627,455 @@ async static IAsyncEnumerable CallbackAsync( } private static string ReplaceWhitespace(string? input) => Regex.Replace(input ?? "", @"\s+", "").Trim(); + + #region AutoWireChatClient + + [Fact] + public async Task AutoWireChatClient_DefaultsToEnabled_EmitsChatSpan_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var fakeChatClient = new AutoWireTestChatClient(); + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + _ = await agent.RunAsync("hi"); + + // Expect 2 activities: the inner chat span (from auto-wired OpenTelemetryChatClient) and the invoke_agent span. + Assert.Equal(2, activities.Count); + Assert.Contains(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal)); + Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal)); + } + + [Fact] + public async Task AutoWireChatClient_Streaming_EmitsChatSpan_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var fakeChatClient = new AutoWireTestChatClient(); + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + await foreach (var _ in agent.RunStreamingAsync("hi")) + { + } + + Assert.Equal(2, activities.Count); + Assert.Contains(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal)); + Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal)); + } + + [Fact] + public async Task AutoWireChatClient_Disabled_DoesNotEmitChatSpan_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var fakeChatClient = new AutoWireTestChatClient(); + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName, autoWireChatClient: false); + + _ = await agent.RunAsync("hi"); + + // Only the invoke_agent activity should be emitted; no chat span. + var activity = Assert.Single(activities); + Assert.StartsWith("invoke_agent", activity.DisplayName); + } + + [Fact] + public async Task AutoWireChatClient_NonChatClientAgent_NoOp_Async() + { + // Inner is not a ChatClientAgent — auto-wiring must be a no-op and options must remain null. + AgentRunOptions? observedOptions = null; + var inner = new TestAIAgent + { + RunAsyncFunc = (messages, session, options, ct) => + { + observedOptions = options; + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "ok"))); + }, + }; + + using var agent = new OpenTelemetryAgent(inner); + + _ = await agent.RunAsync("hi"); + + Assert.Null(observedOptions); + } + + [Fact] + public async Task AutoWireChatClient_UseProvidedChatClientAsIs_DoesNotEmitChatSpan_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var fakeChatClient = new AutoWireTestChatClient(); + var inner = new ChatClientAgent(fakeChatClient, new ChatClientAgentOptions { UseProvidedChatClientAsIs = true }); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + _ = await agent.RunAsync("hi"); + + // UseProvidedChatClientAsIs opts out of auto-wiring, so only the invoke_agent span should be emitted. + var activity = Assert.Single(activities); + Assert.StartsWith("invoke_agent", activity.DisplayName); + } + + [Fact] + public async Task AutoWireChatClient_AlreadyInstrumented_DoesNotDoubleWrap_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var fakeChatClient = new AutoWireTestChatClient(); + // Pre-wrap with OpenTelemetryChatClient on the same source so spans flow through the tracer. + IChatClient preWrapped = fakeChatClient.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(); + var inner = new ChatClientAgent(preWrapped); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + _ = await agent.RunAsync("hi"); + + // Expect exactly 2 activities (one invoke_agent + one chat from the pre-existing wrapper). If we had double-wrapped, we would see 3. + Assert.Equal(2, activities.Count); + } + + [Fact] + public async Task AutoWireChatClient_PreservesUserChatClientFactory_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + bool userFactoryCalled = false; + var fakeChatClient = new AutoWireTestChatClient(); + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + var runOptions = new ChatClientAgentRunOptions + { + ChatClientFactory = cc => + { + userFactoryCalled = true; + return cc; + }, + }; + + _ = await agent.RunAsync("hi", options: runOptions); + + Assert.True(userFactoryCalled); + // Auto-wiring should still produce a chat span on top of the user's factory. + Assert.Equal(2, activities.Count); + Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal)); + } + + [Fact] + public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesBaseProperties_Async() + { + // Auto-wiring converts a plain AgentRunOptions into a ChatClientAgentRunOptions. The base + // properties (ContinuationToken, AllowBackgroundResponses, AdditionalProperties, ResponseFormat) + // must be preserved so they reach the inner agent. + AgentRunOptions? observedOptions = null; + var fakeChatClient = new AutoWireTestChatClient(); + var innerChatClientAgent = new ChatClientAgent(fakeChatClient); + + // Wrapping agent: surfaces the ChatClientAgent via GetService (so auto-wiring activates), + // but captures the AgentRunOptions passed to RunAsync by the OpenTelemetryAgent. + var wrapper = new TestAIAgent + { + GetServiceFunc = (type, key) => + type == typeof(ChatClientAgent) ? innerChatClientAgent : null, + RunAsyncFunc = (messages, session, options, ct) => + { + observedOptions = options; + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "ok"))); + }, + }; + + using var agent = new OpenTelemetryAgent(wrapper); + + var additionalProps = new AdditionalPropertiesDictionary { ["customKey"] = "customValue" }; + var inputOptions = new AgentRunOptions + { + AllowBackgroundResponses = true, + AdditionalProperties = additionalProps, + ResponseFormat = ChatResponseFormat.Json, + }; + + _ = await agent.RunAsync("hi", options: inputOptions); + + Assert.NotNull(observedOptions); + Assert.IsType(observedOptions); + Assert.Equal(true, observedOptions!.AllowBackgroundResponses); + Assert.Same(ChatResponseFormat.Json, observedOptions.ResponseFormat); + Assert.NotNull(observedOptions.AdditionalProperties); + Assert.Equal("customValue", observedOptions.AdditionalProperties!["customKey"]); + } + + [Fact] + public async Task AutoWireChatClient_UserFactoryReturnsInstrumentedClient_DoesNotDoubleWrap_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var fakeChatClient = new AutoWireTestChatClient(); + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + // User factory wraps the chat client with OpenTelemetryChatClient itself. + var runOptions = new ChatClientAgentRunOptions + { + ChatClientFactory = cc => cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(), + }; + + _ = await agent.RunAsync("hi", options: runOptions); + + // Expect 2 activities (invoke_agent + a single chat span). If we double-wrapped, we would see 3. + Assert.Equal(2, activities.Count); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public async Task Ctor_NullOrWhitespaceSourceName_AutoWiredChatClientUsesDefaultSource_Async(string? sourceName) + { + // Both the agent-level invoke_agent span and the auto-wired chat span must be emitted under + // OpenTelemetryConsts.DefaultSourceName when the caller passes null, "", or whitespace, so they reach + // the same ActivitySource and are not silently dropped by the exporter. + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource("Experimental.Microsoft.Agents.AI") + .AddInMemoryExporter(activities) + .Build(); + + var fakeChatClient = new AutoWireTestChatClient(); + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + _ = await agent.RunAsync("hi"); + + Assert.Equal(2, activities.Count); + Assert.All(activities, a => Assert.Equal("Experimental.Microsoft.Agents.AI", a.Source.Name)); + Assert.Contains(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal)); + Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal)); + } + +#pragma warning disable MEAI001 // ResponseContinuationToken is experimental. + [Fact] + public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesContinuationToken_Async() + { + // ContinuationToken is the fourth base AgentRunOptions property copied by CopyBaseAgentRunOptions + // and is not exercised by AutoWireChatClient_PlainAgentRunOptions_PreservesBaseProperties_Async. + AgentRunOptions? observedOptions = null; + var fakeChatClient = new AutoWireTestChatClient(); + var innerChatClientAgent = new ChatClientAgent(fakeChatClient); + + var wrapper = new TestAIAgent + { + GetServiceFunc = (type, key) => + type == typeof(ChatClientAgent) ? innerChatClientAgent : null, + RunAsyncFunc = (messages, session, options, ct) => + { + observedOptions = options; + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, "ok"))); + }, + }; + + using var agent = new OpenTelemetryAgent(wrapper); + + var token = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }); + var inputOptions = new AgentRunOptions + { + ContinuationToken = token, + }; + + _ = await agent.RunAsync("hi", options: inputOptions); + + Assert.NotNull(observedOptions); + Assert.IsType(observedOptions); + Assert.Same(token, observedOptions!.ContinuationToken); + } +#pragma warning restore MEAI001 + + [Fact] + public async Task AutoWireChatClient_ChatClientAgentRunOptions_NoUserFactory_PreservesChatOptions_Async() + { + // When the caller passes a ChatClientAgentRunOptions without a ChatClientFactory, the auto-wiring + // must clone (not mutate) the caller's options, set the factory, and preserve nested ChatOptions. + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + ChatOptions? observedChatOptions = null; + var fakeChatClient = new AutoWireTestChatClient + { + OnGetResponseAsync = (msgs, opts) => observedChatOptions = opts, + }; + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + var inputChatOptions = new ChatOptions { Temperature = 0.42f, ModelId = "test-model" }; + var inputOptions = new ChatClientAgentRunOptions(inputChatOptions); + + _ = await agent.RunAsync("hi", options: inputOptions); + + // Caller's options must not have been mutated (no factory installed on the caller's instance). + Assert.Null(inputOptions.ChatClientFactory); + + // Inner chat client must observe the caller-supplied ChatOptions. + Assert.NotNull(observedChatOptions); + Assert.Equal(0.42f, observedChatOptions!.Temperature); + Assert.Equal("test-model", observedChatOptions.ModelId); + + // Auto-wiring still produces a chat span. + Assert.Equal(2, activities.Count); + Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal)); + } + + [Fact] + public async Task AutoWireChatClient_StreamingDisabled_DoesNotEmitChatSpan_Async() + { + // Symmetry with AutoWireChatClient_Disabled_DoesNotEmitChatSpan_Async for the streaming path. + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var fakeChatClient = new AutoWireTestChatClient(); + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName, autoWireChatClient: false); + + await foreach (var _ in agent.RunStreamingAsync("hi")) + { + } + + var activity = Assert.Single(activities); + Assert.StartsWith("invoke_agent", activity.DisplayName); + } + + [Fact] + public async Task AutoWireChatClient_PlainAgentRunOptions_RealChatClientAgent_EmitsChatSpan_Async() + { + // High-level callers may pass the abstract base AgentRunOptions (not ChatClientAgentRunOptions) when + // wiring a ChatClientAgent. Auto-wiring must still kick in: convert to ChatClientAgentRunOptions, + // install the OTel-wrapping factory, and produce both the invoke_agent and chat spans end-to-end. + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + ChatOptions? observedChatOptions = null; + var fakeChatClient = new AutoWireTestChatClient + { + OnGetResponseAsync = (_, opts) => observedChatOptions = opts, + }; + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + // Pass the base AgentRunOptions, not ChatClientAgentRunOptions. + var inputOptions = new AgentRunOptions { AllowBackgroundResponses = false }; + + _ = await agent.RunAsync("hi", options: inputOptions); + + // Inner chat client was actually invoked (auto-wired factory ran without breaking the pipeline). + Assert.NotNull(observedChatOptions); + + Assert.Equal(2, activities.Count); + Assert.Contains(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal)); + Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal)); + } + + [Fact] + public async Task AutoWireChatClient_PlainAgentRunOptions_RealChatClientAgent_StreamingEmitsChatSpan_Async() + { + // Same as the sync test above but for the streaming path so both invocation paths + // are covered when callers pass a base AgentRunOptions. + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + ChatOptions? observedChatOptions = null; + var fakeChatClient = new AutoWireTestChatClient + { + OnGetResponseAsync = (_, opts) => observedChatOptions = opts, + }; + var inner = new ChatClientAgent(fakeChatClient); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + var inputOptions = new AgentRunOptions { AllowBackgroundResponses = false }; + + await foreach (var _ in agent.RunStreamingAsync("hi", options: inputOptions)) + { + } + + Assert.NotNull(observedChatOptions); + + Assert.Equal(2, activities.Count); + Assert.Contains(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal)); + Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal)); + } + + private sealed class AutoWireTestChatClient : IChatClient + { + public Action, ChatOptions?>? OnGetResponseAsync { get; set; } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + this.OnGetResponseAsync?.Invoke(messages, options); + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "ok"))); + } + + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + this.OnGetResponseAsync?.Invoke(messages, options); + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, "ok"); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType?.IsInstanceOfType(this) == true ? this : null; + + public void Dispose() { } + } + + #endregion }