diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
index c9e0dd23887..31bfde308ba 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
@@ -94,6 +94,12 @@ internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClie
chatBuilder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
}
+ // DeferredOpenTelemetryChatClient is registered last so it sits as the innermost decorator, directly
+ // above the leaf client and below FunctionInvokingChatClient. It is inert until an OpenTelemetryAgent
+ // activates it. Placing OpenTelemetry below FICC ensures the chat span closes before tools are invoked,
+ // so FICC emits execute_tool spans on the agent source.
+ chatBuilder.Use(innerClient => new DeferredOpenTelemetryChatClient(innerClient));
+
var agentChatClient = chatBuilder.Build(services);
if (options?.ChatOptions?.Tools is { Count: > 0 })
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/DeferredOpenTelemetryChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/DeferredOpenTelemetryChatClient.cs
new file mode 100644
index 00000000000..98dc1acf2b7
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/DeferredOpenTelemetryChatClient.cs
@@ -0,0 +1,133 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// A delegating chat client that reserves a position for OpenTelemetry instrumentation directly above
+/// the leaf and below the in a
+/// pipeline.
+///
+///
+///
+/// The slot is inert until is called: it simply forwards to its inner client.
+/// When the agent is wrapped by an , that agent activates the slot with
+/// the resolved source name, at which point the slot routes calls through an
+/// so chat spans are emitted below the
+/// .
+///
+///
+/// Positioning OpenTelemetry below FICC is required for tool telemetry: the chat span then closes before
+/// FICC invokes tools, so is the invoke_agent span and
+/// FICC emits execute_tool spans on the agent source.
+///
+///
+internal sealed class DeferredOpenTelemetryChatClient : DelegatingChatClient
+{
+ private readonly object _activationLock = new();
+ private volatile IChatClient _target;
+ private OpenTelemetryChatClient? _activatedClient;
+
+ ///
+ /// Initializes a new instance of the class in its inert state.
+ ///
+ /// The underlying chat client to forward to until the slot is activated.
+ public DeferredOpenTelemetryChatClient(IChatClient innerClient)
+ : base(innerClient)
+ {
+ this._target = innerClient;
+ }
+
+ /// Gets a value indicating whether the slot has been activated.
+ public bool IsActive => !ReferenceEquals(this._target, this.InnerClient);
+
+ ///
+ /// Gets or sets a value indicating whether the activated should
+ /// include potentially sensitive information (such as message content) in telemetry. Reading or writing
+ /// this property is a no-op while the slot is inert; the owning applies
+ /// and propagates the value once the slot is activated.
+ ///
+ public bool EnableSensitiveData
+ {
+ get => this._activatedClient?.EnableSensitiveData ?? false;
+ set
+ {
+ if (this._activatedClient is { } activatedClient)
+ {
+ activatedClient.EnableSensitiveData = value;
+ }
+ }
+ }
+
+ ///
+ /// Activates the slot so that calls are routed through an wrapping
+ /// the inner client under the specified . Idempotent and thread-safe; a
+ /// second call (or a call after another thread activated the slot) is a no-op.
+ ///
+ /// The telemetry source name to emit chat spans under.
+ public void Activate(string sourceName)
+ {
+ if (this.IsActive)
+ {
+ return;
+ }
+
+ lock (this._activationLock)
+ {
+ if (this.IsActive)
+ {
+ return;
+ }
+
+ var activatedTarget = this.InnerClient.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build();
+
+ // Capture the OpenTelemetryChatClient so the owning agent can propagate EnableSensitiveData to it
+ // (the agent's value may be set after construction, e.g. via the UseOpenTelemetry configure callback).
+ this._activatedClient = activatedTarget.GetService(typeof(OpenTelemetryChatClient)) as OpenTelemetryChatClient;
+ this._target = activatedTarget;
+ }
+ }
+
+ ///
+ public override Task GetResponseAsync(
+ IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
+ this._target.GetResponseAsync(messages, options, cancellationToken);
+
+ ///
+ public override IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
+ this._target.GetStreamingResponseAsync(messages, options, cancellationToken);
+
+ ///
+ public override object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ _ = Throw.IfNull(serviceType);
+
+ // Return this slot for its own type and base contracts; otherwise forward to the current target so
+ // that, once activated, queries such as OpenTelemetryChatClient and ActivitySource resolve to the
+ // activated instrumentation rather than the bare leaf.
+ return serviceKey is null && serviceType.IsInstanceOfType(this)
+ ? this
+ : this._target.GetService(serviceType, serviceKey);
+ }
+
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && !ReferenceEquals(this._target, this.InnerClient))
+ {
+ // When activated, _target is an OpenTelemetryChatClient wrapping the inner client; dispose it so its
+ // own telemetry resources are released. It also disposes the inner client, which is idempotent with the
+ // base.Dispose call below.
+ this._target.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs
index 85ae97e1ded..136b7251e49 100644
--- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs
@@ -42,6 +42,12 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
///
private readonly bool _autoWireChatClient;
+ ///
+ /// The auto-wired below-FICC telemetry slot, when one was activated. Cached so that updates to
+ /// made after construction can be propagated to it.
+ ///
+ private DeferredOpenTelemetryChatClient? _innerTelemetrySlot;
+
/// Initializes a new instance of the class.
/// The underlying to be augmented with telemetry capabilities.
///
@@ -91,6 +97,8 @@ public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName, bool autoWireC
this._otelClient = new OpenTelemetryChatClient(
new ForwardingChatClient(this),
sourceName: this._sourceName);
+
+ this.TryActivateInnerChatClientTelemetry();
}
///
@@ -120,7 +128,16 @@ public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName, bool autoWireC
public bool EnableSensitiveData
{
get => this._otelClient.EnableSensitiveData;
- set => this._otelClient.EnableSensitiveData = value;
+ set
+ {
+ this._otelClient.EnableSensitiveData = value;
+
+ // Keep the auto-wired below-FICC slot in sync so its chat span captures message content too.
+ if (this._innerTelemetrySlot is { } slot)
+ {
+ slot.EnableSensitiveData = value;
+ }
+ }
}
///
@@ -204,84 +221,52 @@ public ForwardedOptions(AgentRunOptions? options, AgentSession? session, Activit
}
///
- /// 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.
+ /// When auto-wiring is enabled and the inner agent is a whose underlying
+ /// is not already instrumented, activates the in-place
+ /// slot so that chat spans are emitted below the
+ /// under this agent's source name. Positioning OpenTelemetry below FICC
+ /// is what allows FICC to emit execute_tool spans on the agent source. Respects
+ /// and is a no-op when no slot is reachable.
///
- private AgentRunOptions? GetRunOptionsWithChatClientWiring(AgentRunOptions? options)
+ private void TryActivateInnerChatClientTelemetry()
{
if (!this._autoWireChatClient)
{
- return options;
+ return;
}
- // The auto-wiring only applies when a ChatClientAgent is reachable from the inner agent. Otherwise, no-op.
+ // 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;
+ return;
}
// Respect ChatClientAgentOptions.UseProvidedChatClientAsIs: don't decorate the chat client when the user opted out.
if (chatClientAgent.GetService()?.UseProvidedChatClientAsIs is true)
{
- return options;
+ return;
}
- // Capture the underlying IChatClient and check whether it is already instrumented.
+ // Don't activate when the chat client is already instrumented (e.g. the caller added their own
+ // OpenTelemetryChatClient), to avoid emitting duplicate chat spans.
var chatClient = chatClientAgent.GetService();
if (chatClient is null || chatClient.GetService(typeof(OpenTelemetryChatClient)) is not null)
{
- return options;
- }
-
- string sourceName = this._sourceName;
- bool enableSensitiveData = this.EnableSensitiveData;
- static IChatClient WrapIfNeeded(IChatClient cc, string sourceName, bool enableSensitiveData) =>
- cc.GetService(typeof(OpenTelemetryChatClient)) is not null
- ? cc
- : cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName, configure: o => o.EnableSensitiveData = enableSensitiveData).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, enableSensitiveData);
- return clone;
+ return;
}
- // 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, enableSensitiveData),
- };
-
- if (options is not null)
+ // Activate the pre-placed slot in-place (below FICC) rather than wrapping a new OpenTelemetryChatClient
+ // around the whole pipeline on each run. Cache it and seed its EnableSensitiveData from the current
+ // value so a later change to this agent's EnableSensitiveData can be propagated to the inner chat span.
+ if (chatClient.GetService(typeof(DeferredOpenTelemetryChatClient)) is DeferredOpenTelemetryChatClient slot)
{
- CopyBaseAgentRunOptions(options, newOptions);
+ slot.Activate(this._sourceName);
+ slot.EnableSensitiveData = this.EnableSensitiveData;
+ this._innerTelemetrySlot = slot;
}
-
- 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 .
///
@@ -295,11 +280,9 @@ 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, runOptions, cancellationToken).ConfigureAwait(false);
+ // Invoke the inner agent. Chat-level telemetry is emitted by the in-place DeferredOpenTelemetryChatClient
+ // slot (below FICC), activated once at construction; no per-run chat-client wiring is needed here.
+ var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Session, fo?.Options, cancellationToken).ConfigureAwait(false);
// Wrap the response in a ChatResponse so we can pass it back through OpenTelemetryChatClient.
return response.AsChatResponse();
@@ -313,11 +296,9 @@ 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, runOptions, cancellationToken).ConfigureAwait(false))
+ // Invoke the inner agent. Chat-level telemetry is emitted by the in-place DeferredOpenTelemetryChatClient
+ // slot (below FICC), activated once at construction; no per-run chat-client wiring is needed here.
+ await foreach (var update in parentAgent.InnerAgent.RunStreamingAsync(messages, fo?.Session, fo?.Options, 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 9717c6157bf..12a8d663f4f 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs
@@ -795,9 +795,9 @@ public async Task AutoWireChatClient_PreservesUserChatClientFactory_Async()
[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.
+ // The auto-wire no longer rewrites the caller's options (the slot below FICC is activated once at
+ // construction), so a plain AgentRunOptions reaches the inner agent unchanged with all base
+ // properties (AllowBackgroundResponses, AdditionalProperties, ResponseFormat) intact.
AgentRunOptions? observedOptions = null;
var fakeChatClient = new AutoWireTestChatClient();
var innerChatClientAgent = new ChatClientAgent(fakeChatClient);
@@ -827,17 +827,27 @@ public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesBasePropertie
_ = await agent.RunAsync("hi", options: inputOptions);
+ // Options flow through unchanged (same instance, no conversion to ChatClientAgentRunOptions).
Assert.NotNull(observedOptions);
- Assert.IsType(observedOptions);
- Assert.Equal(true, observedOptions!.AllowBackgroundResponses);
+ Assert.Same(inputOptions, 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()
+ public async Task AutoWireChatClient_UserFactoryAddsOwnOTel_CoexistsWithBelowFiccSlot_Async()
{
+ // This is NOT a single model call counted twice. One model call is observed by two independent
+ // OpenTelemetry layers, so each layer emits its own "chat" span:
+ // - the framework's slot, always activated below FICC by OpenTelemetryAgent. This below-FICC layer
+ // is what lets FICC emit execute_tool spans, so it must remain even when the caller adds their own
+ // instrumentation. Dropping it to avoid the second span would reintroduce the missing-tool-span bug.
+ // - the caller's per-run ChatClientFactory, which wraps the pipeline above FICC with its own
+ // OpenTelemetryChatClient.
+ // The two chat spans nest and measure different scopes (the above-FICC span covers the whole tool loop,
+ // the below-FICC span covers each individual model call), so both coexisting is the intended result.
var sourceName = Guid.NewGuid().ToString();
var activities = new List();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
@@ -849,7 +859,7 @@ public async Task AutoWireChatClient_UserFactoryReturnsInstrumentedClient_DoesNo
var inner = new ChatClientAgent(fakeChatClient);
using var agent = new OpenTelemetryAgent(inner, sourceName);
- // User factory wraps the chat client with OpenTelemetryChatClient itself.
+ // User factory wraps the chat client with OpenTelemetryChatClient itself (above FICC).
var runOptions = new ChatClientAgentRunOptions
{
ChatClientFactory = cc => cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(),
@@ -857,8 +867,10 @@ public async Task AutoWireChatClient_UserFactoryReturnsInstrumentedClient_DoesNo
_ = 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);
+ // invoke_agent + two chat spans: one from the caller's above-FICC OTel and one from the slot below FICC.
+ Assert.Equal(3, activities.Count);
+ Assert.Contains(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal));
+ Assert.Equal(2, activities.Count(a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal)));
}
[Theory]
@@ -893,8 +905,8 @@ public async Task Ctor_NullOrWhitespaceSourceName_AutoWiredChatClientUsesDefault
[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.
+ // ContinuationToken on a plain AgentRunOptions must reach the inner agent unchanged now that the
+ // auto-wire passes the caller's options straight through (no conversion to ChatClientAgentRunOptions).
AgentRunOptions? observedOptions = null;
var fakeChatClient = new AutoWireTestChatClient();
var innerChatClientAgent = new ChatClientAgent(fakeChatClient);
@@ -921,8 +933,8 @@ public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesContinuationT
_ = await agent.RunAsync("hi", options: inputOptions);
Assert.NotNull(observedOptions);
- Assert.IsType(observedOptions);
- Assert.Same(token, observedOptions!.ContinuationToken);
+ Assert.Same(inputOptions, observedOptions);
+ Assert.Same(token, observedOptions.ContinuationToken);
}
#pragma warning restore MEAI001
@@ -1061,11 +1073,10 @@ public async Task AutoWireChatClient_PlainAgentRunOptions_RealChatClientAgent_St
[InlineData(true, true)]
public async Task AutoWireChatClient_EnableSensitiveData_PropagatedToInnerChatClient_Async(bool enableSensitiveData, bool streaming)
{
- // Regression test for: when EnableSensitiveData is set on OpenTelemetryAgent, the auto-wired
- // inner OpenTelemetryChatClient must also have EnableSensitiveData propagated to it. Previously,
- // GetRunOptionsWithChatClientWiring created the inner client without passing EnableSensitiveData,
- // so the inner chat span would never emit gen_ai.input.messages / gen_ai.output.messages even
- // when the caller explicitly set EnableSensitiveData = true.
+ // Regression test (issue #5873): when EnableSensitiveData is set on OpenTelemetryAgent, the auto-wired
+ // inner OpenTelemetryChatClient (the below-FICC slot) must also have EnableSensitiveData propagated to it,
+ // so the inner chat span captures gen_ai.input.messages / gen_ai.output.messages. The agent sets the value
+ // on the slot after construction, since EnableSensitiveData is typically set via the UseOpenTelemetry callback.
var sourceName = Guid.NewGuid().ToString();
var activities = new List();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
@@ -1109,6 +1120,71 @@ public async Task AutoWireChatClient_EnableSensitiveData_PropagatedToInnerChatCl
}
}
+ [Fact]
+ public async Task AutoWireChatClient_EmitsExecuteToolSpans_Async()
+ {
+ // The core of the OTel-below-FICC fix: with the slot active below FICC, the inner chat span closes
+ // before FICC invokes tools, so Activity.Current is the invoke_agent span and FICC emits an
+ // execute_tool span on the agent source, parented under invoke_agent.
+ var sourceName = Guid.NewGuid().ToString();
+ var activities = new List();
+ using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
+ .AddSource(sourceName)
+ .AddInMemoryExporter(activities)
+ .Build();
+
+ var tool = AIFunctionFactory.Create(() => "sunny", "get_weather");
+ var fakeChatClient = new ToolCallingTestChatClient();
+ var inner = new ChatClientAgent(fakeChatClient, new ChatClientAgentOptions
+ {
+ ChatOptions = new ChatOptions { Tools = [tool] },
+ });
+ using var agent = new OpenTelemetryAgent(inner, sourceName);
+
+ _ = await agent.RunAsync("weather?");
+
+ var invokeAgent = Assert.Single(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal));
+ var executeTool = Assert.Single(activities, a => a.DisplayName.StartsWith("execute_tool", StringComparison.Ordinal));
+ Assert.Equal(sourceName, executeTool.Source.Name);
+ Assert.Equal(invokeAgent.SpanId, executeTool.ParentSpanId);
+ Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task DeferredOpenTelemetryChatClient_InertUntilActivated_Async()
+ {
+ var sourceName = Guid.NewGuid().ToString();
+ var activities = new List();
+ using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
+ .AddSource(sourceName)
+ .AddInMemoryExporter(activities)
+ .Build();
+
+ var leaf = new AutoWireTestChatClient();
+ using var slot = new DeferredOpenTelemetryChatClient(leaf);
+
+ // Inert: resolves itself for its own type and forwards other lookups to the inner client. The bare
+ // leaf is not instrumented, so the OpenTelemetryChatClient lookup is null and no span is emitted.
+ Assert.False(slot.IsActive);
+ Assert.Same(slot, slot.GetService(typeof(DeferredOpenTelemetryChatClient)));
+ Assert.Null(slot.GetService(typeof(OpenTelemetryChatClient)));
+ _ = await slot.GetResponseAsync("hi");
+ Assert.Empty(activities);
+
+ // Active: routes through an OpenTelemetryChatClient that emits a chat span on the source.
+ slot.Activate(sourceName);
+ Assert.True(slot.IsActive);
+ Assert.NotNull(slot.GetService(typeof(OpenTelemetryChatClient)));
+ _ = await slot.GetResponseAsync("hi");
+ var chat = Assert.Single(activities);
+ Assert.Equal("chat", chat.GetTagItem("gen_ai.operation.name") as string);
+
+ // Idempotent: a second activation does not replace the existing wrapper.
+ var target = slot.GetService(typeof(OpenTelemetryChatClient));
+ slot.Activate(sourceName);
+ Assert.Same(target, slot.GetService(typeof(OpenTelemetryChatClient)));
+ }
+
private sealed class AutoWireTestChatClient : IChatClient
{
public Action, ChatOptions?>? OnGetResponseAsync { get; set; }
@@ -1132,5 +1208,40 @@ public async IAsyncEnumerable GetStreamingResponseAsync(IEnu
public void Dispose() { }
}
+ private sealed class ToolCallingTestChatClient : IChatClient
+ {
+ private int _callCount;
+
+ public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ // First call returns a tool call so FICC invokes the tool; the second call returns the final text.
+ if (Interlocked.Increment(ref this._callCount) == 1)
+ {
+ var call = new FunctionCallContent("call_1", "get_weather", new Dictionary());
+ return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, [call])));
+ }
+
+ return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")));
+ }
+
+ public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ if (Interlocked.Increment(ref this._callCount) == 1)
+ {
+ yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("call_1", "get_weather", new Dictionary())]);
+ await Task.Yield();
+ yield break;
+ }
+
+ await Task.Yield();
+ yield return new ChatResponseUpdate(ChatRole.Assistant, "done");
+ }
+
+ public object? GetService(Type serviceType, object? serviceKey = null) =>
+ serviceType?.IsInstanceOfType(this) == true ? this : null;
+
+ public void Dispose() { }
+ }
+
#endregion
}