From ab9c30ee3ed620935ae5256620f259a416cd6f9d Mon Sep 17 00:00:00 2001 From: Peng Fan Date: Mon, 4 May 2026 12:56:05 -0700 Subject: [PATCH 1/2] Fix GetCallerBaggagePairs to resolve userId and userEmail across all channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetCallerBaggagePairs previously only used From.AadObjectId for userId (null on email/Word channels) and never set userEmail. Changes: - userId: fallback chain AadObjectId → AgenticUserId → From.Id - userEmail: use From.Id when SubChannel is present (email/Word/SPO) or From.Id matches email format; also check AgenticUserId for A2A calls where the calling agent has an email-based identity Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Extensions/TurnContextExtensions.cs | 17 ++- .../Middleware/BaggageTurnMiddlewareTests.cs | 123 +++++++++++++++++- 2 files changed, 134 insertions(+), 6 deletions(-) diff --git a/src/Observability/Hosting/Extensions/TurnContextExtensions.cs b/src/Observability/Hosting/Extensions/TurnContextExtensions.cs index 5ea8f8bc..2d2681bf 100644 --- a/src/Observability/Hosting/Extensions/TurnContextExtensions.cs +++ b/src/Observability/Hosting/Extensions/TurnContextExtensions.cs @@ -20,8 +20,21 @@ public static class TurnContextExtensions /// public static IEnumerable> GetCallerBaggagePairs(this ITurnContext turnContext) { - yield return new KeyValuePair(OpenTelemetryConstants.UserIdKey, turnContext.Activity?.From?.AadObjectId); - yield return new KeyValuePair(OpenTelemetryConstants.UserNameKey, turnContext.Activity?.From?.Name); + var from = turnContext.Activity?.From; + var hasSubChannel = !string.IsNullOrEmpty(turnContext.Activity?.ChannelId?.SubChannel); + var userEmail = hasSubChannel || IsEmail(from?.Id) + ? from?.Id + : IsEmail(from?.AgenticUserId) ? from?.AgenticUserId : null; + yield return new KeyValuePair(OpenTelemetryConstants.UserIdKey, from?.AadObjectId ?? from?.AgenticUserId ?? from?.Id); + yield return new KeyValuePair(OpenTelemetryConstants.UserNameKey, from?.Name); + yield return new KeyValuePair(OpenTelemetryConstants.UserEmailKey, userEmail); + } + + private static readonly System.Text.RegularExpressions.Regex EmailPattern = new System.Text.RegularExpressions.Regex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", System.Text.RegularExpressions.RegexOptions.Compiled); + + private static bool IsEmail(string? value) + { + return !string.IsNullOrEmpty(value) && EmailPattern.IsMatch(value!); } /// diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Middleware/BaggageTurnMiddlewareTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Middleware/BaggageTurnMiddlewareTests.cs index e5cde121..32098bf9 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Middleware/BaggageTurnMiddlewareTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Middleware/BaggageTurnMiddlewareTests.cs @@ -107,9 +107,123 @@ public async Task OnTurnAsync_RestoresBaggageAfterNext() baggageAfterMiddleware.Should().Be(baggageBeforeMiddleware); } + [TestMethod] + public async Task OnTurnAsync_SetsUserIdAndEmail_WhenEmailChannel() + { + // Arrange — simulates email channel where AadObjectId is null and SubChannel is set + var middleware = new BaggageTurnMiddleware(); + var turnContext = CreateTurnContext( + fromId: "lukemoenning@microsoft.com", + fromAadObjectId: null, + subChannel: "email"); + + string? capturedCallerId = null; + string? capturedUserEmail = null; + + NextDelegate next = (ct) => + { + capturedCallerId = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserIdKey); + capturedUserEmail = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserEmailKey); + return Task.CompletedTask; + }; + + // Act + await middleware.OnTurnAsync(turnContext, next); + + // Assert + capturedCallerId.Should().Be("lukemoenning@microsoft.com"); + capturedUserEmail.Should().Be("lukemoenning@microsoft.com"); + } + + [TestMethod] + public async Task OnTurnAsync_DoesNotSetUserEmail_WhenTeamsChannel() + { + // Arrange — simulates Teams channel (no SubChannel) + var middleware = new BaggageTurnMiddleware(); + var turnContext = CreateTurnContext( + fromId: "8:orgid:17649762-cd35-4a35-95ab-75eeb3017308"); + + string? capturedCallerId = null; + string? capturedUserEmail = null; + + NextDelegate next = (ct) => + { + capturedCallerId = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserIdKey); + capturedUserEmail = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserEmailKey); + return Task.CompletedTask; + }; + + // Act + await middleware.OnTurnAsync(turnContext, next); + + // Assert + capturedCallerId.Should().Be("caller-aad"); + capturedUserEmail.Should().BeNull(); + } + + [TestMethod] + public async Task OnTurnAsync_SetsUserEmailFromAgenticUserId_WhenA2AWithEmail() + { + // Arrange — simulates A2A where the calling agent has an email-based agenticUserId + var middleware = new BaggageTurnMiddleware(); + var turnContext = CreateTurnContext( + fromId: "29:1sH5NArUwkWAX", + fromAadObjectId: null, + fromAgenticUserId: "agent@contoso.onmicrosoft.com"); + + string? capturedCallerId = null; + string? capturedUserEmail = null; + + NextDelegate next = (ct) => + { + capturedCallerId = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserIdKey); + capturedUserEmail = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserEmailKey); + return Task.CompletedTask; + }; + + // Act + await middleware.OnTurnAsync(turnContext, next); + + // Assert + capturedCallerId.Should().Be("agent@contoso.onmicrosoft.com"); + capturedUserEmail.Should().Be("agent@contoso.onmicrosoft.com"); + } + + [TestMethod] + public async Task OnTurnAsync_DoesNotSetUserEmail_WhenA2AWithGuidAgenticUserId() + { + // Arrange — simulates A2A where agenticUserId is a GUID, not an email + var middleware = new BaggageTurnMiddleware(); + var turnContext = CreateTurnContext( + fromId: "29:1sH5NArUwkWAX", + fromAadObjectId: null, + fromAgenticUserId: "bef730f4-d6f5-4ffb-b759-26ffa449ed7e"); + + string? capturedCallerId = null; + string? capturedUserEmail = null; + + NextDelegate next = (ct) => + { + capturedCallerId = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserIdKey); + capturedUserEmail = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserEmailKey); + return Task.CompletedTask; + }; + + // Act + await middleware.OnTurnAsync(turnContext, next); + + // Assert + capturedCallerId.Should().Be("bef730f4-d6f5-4ffb-b759-26ffa449ed7e"); + capturedUserEmail.Should().BeNull(); + } + private static ITurnContext CreateTurnContext( string activityType = "message", - string? activityName = null) + string? activityName = null, + string? fromId = "caller-id", + string? fromAadObjectId = "caller-aad", + string? fromAgenticUserId = null, + string? subChannel = null) { var mockActivity = new Mock(); mockActivity.Setup(a => a.Type).Returns(activityType); @@ -120,9 +234,10 @@ private static ITurnContext CreateTurnContext( mockActivity.Setup(a => a.Text).Returns("Hello"); mockActivity.Setup(a => a.From).Returns(new ChannelAccount { - Id = "caller-id", + Id = fromId, Name = "Caller", - AadObjectId = "caller-aad", + AadObjectId = fromAadObjectId, + AgenticUserId = fromAgenticUserId, }); mockActivity.Setup(a => a.Recipient).Returns(new ChannelAccount { @@ -133,7 +248,7 @@ private static ITurnContext CreateTurnContext( }); mockActivity.Setup(a => a.Conversation).Returns(new ConversationAccount { Id = "conv-id" }); mockActivity.Setup(a => a.ServiceUrl).Returns("https://example.com"); - mockActivity.Setup(a => a.ChannelId).Returns(new ChannelId("test-channel")); + mockActivity.Setup(a => a.ChannelId).Returns(new ChannelId("test-channel") { SubChannel = subChannel }); var mockTurnContext = new Mock(); mockTurnContext.Setup(tc => tc.Activity).Returns(mockActivity.Object); From 7c20291f53fee83745405020fab1b16d4c2cb9ea Mon Sep 17 00:00:00 2001 From: Peng Fan Date: Mon, 4 May 2026 14:00:30 -0700 Subject: [PATCH 2/2] Address review: remove email logic, keep userId fallback chain only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove SubChannel check, IsEmail helper, EmailPattern regex, and userEmail field - Keep userId fallback: AadObjectId → AgenticUserId → From.Id - Replace email-specific tests with userId-focused tests covering all fallback paths - Add precedence test: AadObjectId wins when both AadObjectId and AgenticUserId are set - Rename capturedCallerId → capturedUserId in new tests - Use realistic channel names (msteams, outlook) instead of test-channel Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Extensions/TurnContextExtensions.cs | 12 ---- .../Middleware/BaggageTurnMiddlewareTests.cs | 69 ++++++++----------- 2 files changed, 30 insertions(+), 51 deletions(-) diff --git a/src/Observability/Hosting/Extensions/TurnContextExtensions.cs b/src/Observability/Hosting/Extensions/TurnContextExtensions.cs index 2d2681bf..f187824c 100644 --- a/src/Observability/Hosting/Extensions/TurnContextExtensions.cs +++ b/src/Observability/Hosting/Extensions/TurnContextExtensions.cs @@ -21,20 +21,8 @@ public static class TurnContextExtensions public static IEnumerable> GetCallerBaggagePairs(this ITurnContext turnContext) { var from = turnContext.Activity?.From; - var hasSubChannel = !string.IsNullOrEmpty(turnContext.Activity?.ChannelId?.SubChannel); - var userEmail = hasSubChannel || IsEmail(from?.Id) - ? from?.Id - : IsEmail(from?.AgenticUserId) ? from?.AgenticUserId : null; yield return new KeyValuePair(OpenTelemetryConstants.UserIdKey, from?.AadObjectId ?? from?.AgenticUserId ?? from?.Id); yield return new KeyValuePair(OpenTelemetryConstants.UserNameKey, from?.Name); - yield return new KeyValuePair(OpenTelemetryConstants.UserEmailKey, userEmail); - } - - private static readonly System.Text.RegularExpressions.Regex EmailPattern = new System.Text.RegularExpressions.Regex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", System.Text.RegularExpressions.RegexOptions.Compiled); - - private static bool IsEmail(string? value) - { - return !string.IsNullOrEmpty(value) && EmailPattern.IsMatch(value!); } /// diff --git a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Middleware/BaggageTurnMiddlewareTests.cs b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Middleware/BaggageTurnMiddlewareTests.cs index 32098bf9..c090c241 100644 --- a/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Middleware/BaggageTurnMiddlewareTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Observability.Hosting.Tests/Middleware/BaggageTurnMiddlewareTests.cs @@ -108,22 +108,20 @@ public async Task OnTurnAsync_RestoresBaggageAfterNext() } [TestMethod] - public async Task OnTurnAsync_SetsUserIdAndEmail_WhenEmailChannel() + public async Task OnTurnAsync_UserId_FallsBackToFromId_WhenAadObjectIdIsNull() { - // Arrange — simulates email channel where AadObjectId is null and SubChannel is set + // Arrange — simulates email/Word/SPO channel where AadObjectId is null var middleware = new BaggageTurnMiddleware(); var turnContext = CreateTurnContext( + channelName: "outlook", fromId: "lukemoenning@microsoft.com", - fromAadObjectId: null, - subChannel: "email"); + fromAadObjectId: null); - string? capturedCallerId = null; - string? capturedUserEmail = null; + string? capturedUserId = null; NextDelegate next = (ct) => { - capturedCallerId = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserIdKey); - capturedUserEmail = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserEmailKey); + capturedUserId = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserIdKey); return Task.CompletedTask; }; @@ -131,25 +129,24 @@ public async Task OnTurnAsync_SetsUserIdAndEmail_WhenEmailChannel() await middleware.OnTurnAsync(turnContext, next); // Assert - capturedCallerId.Should().Be("lukemoenning@microsoft.com"); - capturedUserEmail.Should().Be("lukemoenning@microsoft.com"); + capturedUserId.Should().Be("lukemoenning@microsoft.com"); } [TestMethod] - public async Task OnTurnAsync_DoesNotSetUserEmail_WhenTeamsChannel() + public async Task OnTurnAsync_UserId_FallsBackToAgenticUserId_WhenAadObjectIdIsNull() { - // Arrange — simulates Teams channel (no SubChannel) + // Arrange — simulates A2A call where AadObjectId is null but AgenticUserId is set var middleware = new BaggageTurnMiddleware(); var turnContext = CreateTurnContext( - fromId: "8:orgid:17649762-cd35-4a35-95ab-75eeb3017308"); + fromId: "29:1sH5NArUwkWAX", + fromAadObjectId: null, + fromAgenticUserId: "agent@contoso.onmicrosoft.com"); - string? capturedCallerId = null; - string? capturedUserEmail = null; + string? capturedUserId = null; NextDelegate next = (ct) => { - capturedCallerId = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserIdKey); - capturedUserEmail = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserEmailKey); + capturedUserId = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserIdKey); return Task.CompletedTask; }; @@ -157,27 +154,25 @@ public async Task OnTurnAsync_DoesNotSetUserEmail_WhenTeamsChannel() await middleware.OnTurnAsync(turnContext, next); // Assert - capturedCallerId.Should().Be("caller-aad"); - capturedUserEmail.Should().BeNull(); + capturedUserId.Should().Be("agent@contoso.onmicrosoft.com"); } [TestMethod] - public async Task OnTurnAsync_SetsUserEmailFromAgenticUserId_WhenA2AWithEmail() + public async Task OnTurnAsync_UserId_PrefersAadObjectId_WhenBothAadAndAgenticUserIdSet() { - // Arrange — simulates A2A where the calling agent has an email-based agenticUserId + // Arrange — both AadObjectId and AgenticUserId are populated; AadObjectId should win var middleware = new BaggageTurnMiddleware(); var turnContext = CreateTurnContext( - fromId: "29:1sH5NArUwkWAX", - fromAadObjectId: null, + channelName: "msteams", + fromId: "8:orgid:17649762-cd35-4a35-95ab-75eeb3017308", + fromAadObjectId: "aad-object-id-123", fromAgenticUserId: "agent@contoso.onmicrosoft.com"); - string? capturedCallerId = null; - string? capturedUserEmail = null; + string? capturedUserId = null; NextDelegate next = (ct) => { - capturedCallerId = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserIdKey); - capturedUserEmail = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserEmailKey); + capturedUserId = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserIdKey); return Task.CompletedTask; }; @@ -185,27 +180,24 @@ public async Task OnTurnAsync_SetsUserEmailFromAgenticUserId_WhenA2AWithEmail() await middleware.OnTurnAsync(turnContext, next); // Assert - capturedCallerId.Should().Be("agent@contoso.onmicrosoft.com"); - capturedUserEmail.Should().Be("agent@contoso.onmicrosoft.com"); + capturedUserId.Should().Be("aad-object-id-123"); } [TestMethod] - public async Task OnTurnAsync_DoesNotSetUserEmail_WhenA2AWithGuidAgenticUserId() + public async Task OnTurnAsync_UserId_FallsBackToGuidAgenticUserId() { - // Arrange — simulates A2A where agenticUserId is a GUID, not an email + // Arrange — A2A where AgenticUserId is a GUID, not an email var middleware = new BaggageTurnMiddleware(); var turnContext = CreateTurnContext( fromId: "29:1sH5NArUwkWAX", fromAadObjectId: null, fromAgenticUserId: "bef730f4-d6f5-4ffb-b759-26ffa449ed7e"); - string? capturedCallerId = null; - string? capturedUserEmail = null; + string? capturedUserId = null; NextDelegate next = (ct) => { - capturedCallerId = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserIdKey); - capturedUserEmail = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserEmailKey); + capturedUserId = Baggage.Current.GetBaggage(OpenTelemetryConstants.UserIdKey); return Task.CompletedTask; }; @@ -213,8 +205,7 @@ public async Task OnTurnAsync_DoesNotSetUserEmail_WhenA2AWithGuidAgenticUserId() await middleware.OnTurnAsync(turnContext, next); // Assert - capturedCallerId.Should().Be("bef730f4-d6f5-4ffb-b759-26ffa449ed7e"); - capturedUserEmail.Should().BeNull(); + capturedUserId.Should().Be("bef730f4-d6f5-4ffb-b759-26ffa449ed7e"); } private static ITurnContext CreateTurnContext( @@ -223,7 +214,7 @@ private static ITurnContext CreateTurnContext( string? fromId = "caller-id", string? fromAadObjectId = "caller-aad", string? fromAgenticUserId = null, - string? subChannel = null) + string channelName = "msteams") { var mockActivity = new Mock(); mockActivity.Setup(a => a.Type).Returns(activityType); @@ -248,7 +239,7 @@ private static ITurnContext CreateTurnContext( }); mockActivity.Setup(a => a.Conversation).Returns(new ConversationAccount { Id = "conv-id" }); mockActivity.Setup(a => a.ServiceUrl).Returns("https://example.com"); - mockActivity.Setup(a => a.ChannelId).Returns(new ChannelId("test-channel") { SubChannel = subChannel }); + mockActivity.Setup(a => a.ChannelId).Returns(new ChannelId(channelName)); var mockTurnContext = new Mock(); mockTurnContext.Setup(tc => tc.Activity).Returns(mockActivity.Object);