Skip to content

Commit 40a2dd5

Browse files
.NET: Restore ambient client-header scope between non-streaming ClientHeadersAgent runs (#6517)
* Restore ambient client-header scope between non-streaming runs (#6516) Make ClientHeadersAgent.RunCoreAsync async + await so the per-run ClientHeadersScope is unwound on return, matching the streaming path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Assert per-run on wire instead of brittle exact request count Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7e9c043 commit 40a2dd5

2 files changed

Lines changed: 84 additions & 5 deletions

File tree

dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersAgent.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public ClientHeadersAgent(AIAgent innerAgent)
3535
}
3636

3737
/// <inheritdoc/>
38-
protected override Task<AgentResponse> RunCoreAsync(
38+
protected override async Task<AgentResponse> RunCoreAsync(
3939
IEnumerable<ChatMessage> messages,
4040
AgentSession? session = null,
4141
AgentRunOptions? options = null,
@@ -44,13 +44,15 @@ protected override Task<AgentResponse> RunCoreAsync(
4444
var snapshot = TrySnapshot(options);
4545
if (snapshot is not null)
4646
{
47-
// AsyncLocal mutations made inside an awaited async method do not leak back to the
48-
// caller after the method returns, so we do not need an explicit restore step here.
49-
// See ClientHeadersScope remarks.
47+
// This method is async, so the runtime restores the caller's ExecutionContext (and
48+
// therefore the previous ClientHeadersScope.Current value) when the returned task
49+
// completes. Awaiting the inner call is what establishes that async-method boundary,
50+
// so the per-run scope set here cannot carry into a later run on the same async flow.
51+
// See ClientHeadersScope remarks. The streaming path relies on the same behavior.
5052
ClientHeadersScope.Current = snapshot;
5153
}
5254

53-
return this.InnerAgent.RunAsync(messages, session, options, cancellationToken);
55+
return await this.InnerAgent.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false);
5456
}
5557

5658
/// <inheritdoc/>

dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ClientHeadersExtensionsTests.cs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.ClientModel;
55
using System.ClientModel.Primitives;
66
using System.Collections.Generic;
7+
using System.Linq;
78
using System.Net;
89
using System.Net.Http;
910
using System.Reflection;
@@ -561,6 +562,82 @@ public void UseClientHeaders_RepeatedRegistrations_OnSameChatClient_OnlyRegister
561562
Assert.Equal(1, EntriesCount(policies!));
562563
}
563564

565+
// -------------------------------------------------------------------------------------------
566+
// 21. Non-streaming hardening: a non-streaming run must restore the ambient ClientHeadersScope
567+
// on return so a previous run's x-client-* headers do not carry into a later headerless run
568+
// on the same async flow. (Streaming already restores naturally via its async iterator.)
569+
// -------------------------------------------------------------------------------------------
570+
571+
[Fact]
572+
public async Task NonStreaming_DoesNotCarryClientHeadersToSubsequentRunAsync()
573+
{
574+
// Arrange: a probe inner agent records ClientHeadersScope.Current observed at each run.
575+
var observed = new List<IReadOnlyDictionary<string, string>?>();
576+
var inner = new ProbeAgent(_ =>
577+
{
578+
observed.Add(ClientHeadersScope.Current);
579+
return Task.CompletedTask;
580+
});
581+
var agent = new ClientHeadersAgent(inner);
582+
583+
// Act: run 1 supplies a client header; run 2 supplies fresh, empty ChatOptions (no headers).
584+
var run1Options = new ChatOptions();
585+
run1Options.WithClientHeader("x-client-end-user-id", "alice");
586+
await agent.RunAsync(messages: [], options: new ChatClientAgentRunOptions(run1Options));
587+
588+
// The scope must not carry back into the caller's flow after run 1 returns.
589+
Assert.Null(ClientHeadersScope.Current);
590+
591+
var run2Options = new ChatOptions();
592+
await agent.RunAsync(messages: [], options: new ChatClientAgentRunOptions(run2Options));
593+
594+
// Assert: run 1 observed "alice"; run 2 observed no headers (did not inherit run 1's value).
595+
Assert.Equal(2, observed.Count);
596+
Assert.NotNull(observed[0]);
597+
Assert.Equal("alice", observed[0]!["x-client-end-user-id"]);
598+
Assert.Null(observed[1]);
599+
Assert.Null(ClientHeadersScope.Current);
600+
}
601+
602+
// -------------------------------------------------------------------------------------------
603+
// 22. End-to-end non-streaming: a second headerless run on the same async flow must not carry
604+
// the first run's x-client-end-user-id onto the wire.
605+
// -------------------------------------------------------------------------------------------
606+
607+
[Fact]
608+
public async Task EndToEnd_NonStreaming_SecondRunDoesNotInheritHeaderOnWireAsync()
609+
{
610+
// Arrange: a real OpenAI ResponsesClient pointed at a recording handler.
611+
using var handler = new RecordingHandler(MinimalResponseJson());
612+
#pragma warning disable CA5399
613+
using var http = new HttpClient(handler);
614+
#pragma warning restore CA5399
615+
var openAIOptions = new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) };
616+
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"), openAIOptions);
617+
IChatClient chatClient = openAIClient.GetResponsesClient().AsIChatClient();
618+
619+
AIAgent agent = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
620+
621+
// Act: run 1 carries x-client-end-user-id; run 2 supplies fresh options with no client headers.
622+
var run1 = new ChatClientAgentRunOptions(new ChatOptions());
623+
run1.ChatOptions!.WithClientHeader("x-client-end-user-id", "alice");
624+
await agent.RunAsync("hi", options: run1);
625+
var afterRun1 = handler.Requests.Count;
626+
627+
var run2 = new ChatClientAgentRunOptions(new ChatOptions());
628+
await agent.RunAsync("hi", options: run2);
629+
630+
// Assert: run 1 stamped the header; none of the requests issued by run 2 carry it.
631+
// (Assert per-run rather than on an exact total count, which would be brittle to
632+
// any extra/internal SDK requests.)
633+
Assert.True(afterRun1 > 0);
634+
Assert.Equal("alice", handler.Requests[0].Headers["x-client-end-user-id"]);
635+
636+
var run2Requests = handler.Requests.Skip(afterRun1).ToList();
637+
Assert.NotEmpty(run2Requests);
638+
Assert.All(run2Requests, r => Assert.False(r.Headers.ContainsKey("x-client-end-user-id")));
639+
}
640+
564641
// -------------------------------------------------------------------------------------------
565642
// Helpers
566643
// -------------------------------------------------------------------------------------------

0 commit comments

Comments
 (0)