From 2022e837af5335072b68b6674e23dbaa96390404 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 28 Aug 2026 08:52:50 -0400 Subject: [PATCH 1/9] feat: adds the executorId as an additional property when running the workflow as agent in dotnet --- .../AgentResponseUpdateEvent.cs | 11 +- .../WorkflowAgentAdditionalProperties.cs | 14 +++ .../WorkflowHostSmokeTests.cs | 103 ++++++++++++++++++ 3 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowAgentAdditionalProperties.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs index f3d5215ccd3..84ee1bfe7ec 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs @@ -15,9 +15,8 @@ public sealed class AgentResponseUpdateEvent : WorkflowOutputEvent /// /// The identifier of the executor that generated this event. /// The agent run response update. - public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update) : base(update, executorId) + public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update) : this(executorId, update, tags: null) { - this.Update = Throw.IfNull(update); } /// @@ -26,9 +25,8 @@ public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update) : /// The identifier of the executor that generated this event. /// The agent run response update. /// The output tag to associate with this event. - public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update, OutputTag tag) : base(update, executorId, tag) + public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update, OutputTag tag) : this(executorId, update, [tag]) { - this.Update = Throw.IfNull(update); } /// @@ -40,6 +38,11 @@ public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update, O public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update, IEnumerable? tags) : base(update, executorId, tags) { this.Update = Throw.IfNull(update); + if (!string.IsNullOrEmpty(executorId)) + { + this.Update.AdditionalProperties ??= []; + this.Update.AdditionalProperties.TryAdd(WorkflowAgentAdditionalProperties.ExecutorId, executorId); + } } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowAgentAdditionalProperties.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowAgentAdditionalProperties.cs new file mode 100644 index 00000000000..5e9410e4144 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowAgentAdditionalProperties.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Defines additional property keys used by workflow-hosted agents. +/// +public static class WorkflowAgentAdditionalProperties +{ + /// + /// The key for the workflow executor identifier that produced an agent response update. + /// + public const string ExecutorId = "executorId"; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index 92ebc5915d8..ab2e2ba5d7c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -206,6 +206,54 @@ await context.SendMessageAsync( } } +internal sealed class AttributionTestExecutor : ChatProtocolExecutor +{ + private static readonly ChatProtocolExecutorOptions s_options = new() + { + AutoSendTurnToken = false, + }; + + private readonly string _responseText; + private readonly string? _downstreamExecutorId; + private readonly AdditionalPropertiesDictionary _additionalProperties; + private readonly object _rawRepresentation; + + public AttributionTestExecutor( + string id, + string responseText, + AdditionalPropertiesDictionary additionalProperties, + object rawRepresentation, + string? downstreamExecutorId = null) + : base(id, s_options) + { + this._responseText = responseText; + this._downstreamExecutorId = downstreamExecutorId; + this._additionalProperties = additionalProperties; + this._rawRepresentation = rawRepresentation; + } + + protected override async ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) + { + AgentResponseUpdate update = new(ChatRole.Assistant, [new TextContent(this._responseText)]) + { + AdditionalProperties = this._additionalProperties, + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + RawRepresentation = this._rawRepresentation, + ResponseId = Guid.NewGuid().ToString("N"), + Role = ChatRole.Assistant, + }; + + await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false); + + if (this._downstreamExecutorId is not null && messages.Any(message => message.Role == ChatRole.User)) + { + await context.SendMessageAsync(messages, this._downstreamExecutorId, cancellationToken).ConfigureAwait(false); + await context.SendMessageAsync(new TurnToken(emitEvents), this._downstreamExecutorId, cancellationToken).ConfigureAwait(false); + } + } +} + public class NonChatProtocolExecutor() : Executor(nameof(NonChatProtocolExecutor)) { public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) @@ -863,6 +911,61 @@ public async Task Test_AsAgent_UsesDesignatedWorkflowOutputInsteadOfIntermediate .Which.Text.Should().Be("SECOND ANSWER"); } + [Fact] + public async Task Test_AsAgent_ResponseUpdatesIncludeWorkflowExecutorIdAdditionalPropertyAsync() + { + // Arrange + const string ExistingMetadataKey = "provider-metadata"; + const string StartExecutorId = "start-executor"; + const string DownstreamExecutorId = "downstream-executor"; + const string StartResponseText = "from start"; + const string DownstreamResponseText = "from downstream"; + + object startRawRepresentation = new(); + object downstreamRawRepresentation = new(); + AttributionTestExecutor downstreamExecutor = new( + DownstreamExecutorId, + DownstreamResponseText, + new AdditionalPropertiesDictionary { [ExistingMetadataKey] = "downstream metadata" }, + downstreamRawRepresentation); + AttributionTestExecutor startExecutor = new( + StartExecutorId, + StartResponseText, + new AdditionalPropertiesDictionary { [ExistingMetadataKey] = "start metadata" }, + startRawRepresentation, + DownstreamExecutorId); + + ExecutorBinding startBinding = startExecutor.BindExecutor(); + ExecutorBinding downstreamBinding = downstreamExecutor.BindExecutor(); + Workflow workflow = new WorkflowBuilder(startBinding) + .AddEdge>(startBinding, downstreamBinding, static messages => messages is { Count: > 0 }) + .AddEdge(startBinding, downstreamBinding, _ => true) + .Build(); + + // Act + List updates = await workflow + .AsAIAgent("WorkflowAgent") + .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) + .ToListAsync(); + + // Assert + AgentResponseUpdate startUpdate = updates.Should().ContainSingle(update => update.Text == StartResponseText).Subject; + startUpdate.AdditionalProperties.Should().NotBeNull(); + startUpdate.AdditionalProperties.Should().ContainKey(ExistingMetadataKey) + .WhoseValue.Should().Be("start metadata"); + startUpdate.AdditionalProperties.Should().ContainKey(WorkflowAgentAdditionalProperties.ExecutorId) + .WhoseValue.Should().Be(StartExecutorId); + startUpdate.RawRepresentation.Should().BeSameAs(startRawRepresentation); + + AgentResponseUpdate downstreamUpdate = updates.Should().ContainSingle(update => update.Text == DownstreamResponseText).Subject; + downstreamUpdate.AdditionalProperties.Should().NotBeNull(); + downstreamUpdate.AdditionalProperties.Should().ContainKey(ExistingMetadataKey) + .WhoseValue.Should().Be("downstream metadata"); + downstreamUpdate.AdditionalProperties.Should().ContainKey(WorkflowAgentAdditionalProperties.ExecutorId) + .WhoseValue.Should().Be(DownstreamExecutorId); + downstreamUpdate.RawRepresentation.Should().BeSameAs(downstreamRawRepresentation); + } + // ----- Phase 5: Workflow-as-Agent intermediate forwarding ----------------- [Collection(Futures.FuturesSerialCollection.Name)] From 5366f3533ef378d92d7927ca196173b435d135e9 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 28 Aug 2026 08:57:24 -0400 Subject: [PATCH 2/9] chore: linting Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs index 84ee1bfe7ec..f4b07a1b319 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs @@ -41,7 +41,7 @@ public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update, I if (!string.IsNullOrEmpty(executorId)) { this.Update.AdditionalProperties ??= []; - this.Update.AdditionalProperties.TryAdd(WorkflowAgentAdditionalProperties.ExecutorId, executorId); + this.Update.AdditionalProperties[WorkflowAgentAdditionalProperties.ExecutorId] = executorId; } } From 50606c7a1b49154b05a4edd8dd6066408031096f Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 28 Aug 2026 09:04:56 -0400 Subject: [PATCH 3/9] feat: adds the executor id to the response event to match behaviour across execution methods Signed-off-by: Vincent Biret --- .../AgentResponseEvent.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseEvent.cs index 5d59366a201..8e08f9eb66d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseEvent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseEvent.cs @@ -15,9 +15,8 @@ public sealed class AgentResponseEvent : WorkflowOutputEvent /// /// The identifier of the executor that generated this event. /// The agent response. - public AgentResponseEvent(string executorId, AgentResponse response) : base(response, executorId) + public AgentResponseEvent(string executorId, AgentResponse response) : this(executorId, response, tags: null) { - this.Response = Throw.IfNull(response); } /// @@ -26,9 +25,8 @@ public AgentResponseEvent(string executorId, AgentResponse response) : base(resp /// The identifier of the executor that generated this event. /// The agent response. /// The output tag to associate with this event. - public AgentResponseEvent(string executorId, AgentResponse response, OutputTag tag) : base(response, executorId, tag) + public AgentResponseEvent(string executorId, AgentResponse response, OutputTag tag) : this(executorId, response, [tag]) { - this.Response = Throw.IfNull(response); } /// @@ -40,6 +38,11 @@ public AgentResponseEvent(string executorId, AgentResponse response, OutputTag t public AgentResponseEvent(string executorId, AgentResponse response, IEnumerable? tags) : base(response, executorId, tags) { this.Response = Throw.IfNull(response); + if (!string.IsNullOrEmpty(executorId)) + { + this.Response.AdditionalProperties ??= []; + this.Response.AdditionalProperties[WorkflowAgentAdditionalProperties.ExecutorId] = executorId; + } } /// From 5b418c4b6775745d278df91d48af316f6e40b0c4 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 28 Aug 2026 09:09:03 -0400 Subject: [PATCH 4/9] tests: adds a regression test to avoid value being wrong because of previous inputs for the executorId Signed-off-by: Vincent Biret --- .../WorkflowHostSmokeTests.cs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index ab2e2ba5d7c..a2f2973dfe9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -966,6 +966,44 @@ public async Task Test_AsAgent_ResponseUpdatesIncludeWorkflowExecutorIdAdditiona downstreamUpdate.RawRepresentation.Should().BeSameAs(downstreamRawRepresentation); } + [Fact] + public async Task Test_AsAgent_ResponseUpdatesOverwriteProviderExecutorIdAdditionalPropertyAsync() + { + // Arrange + const string ExistingMetadataKey = "provider-metadata"; + const string ExecutorId = "authoritative-executor"; + const string ProviderExecutorId = "provider-controlled-executor"; + const string ResponseText = "from executor"; + + object rawRepresentation = new(); + AttributionTestExecutor executor = new( + ExecutorId, + ResponseText, + new AdditionalPropertiesDictionary + { + [ExistingMetadataKey] = "provider metadata", + [WorkflowAgentAdditionalProperties.ExecutorId] = ProviderExecutorId, + }, + rawRepresentation); + + Workflow workflow = new WorkflowBuilder(executor.BindExecutor()).Build(); + + // Act + List updates = await workflow + .AsAIAgent("WorkflowAgent") + .RunStreamingAsync(new ChatMessage(ChatRole.User, "start")) + .ToListAsync(); + + // Assert + AgentResponseUpdate update = updates.Should().ContainSingle(item => item.Text == ResponseText).Subject; + update.AdditionalProperties.Should().NotBeNull(); + update.AdditionalProperties.Should().ContainKey(ExistingMetadataKey) + .WhoseValue.Should().Be("provider metadata"); + update.AdditionalProperties.Should().ContainKey(WorkflowAgentAdditionalProperties.ExecutorId) + .WhoseValue.Should().Be(ExecutorId); + update.RawRepresentation.Should().BeSameAs(rawRepresentation); + } + // ----- Phase 5: Workflow-as-Agent intermediate forwarding ----------------- [Collection(Futures.FuturesSerialCollection.Name)] From a96df6307ac7094a3b3f8c11c5ff286356c403a3 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 28 Aug 2026 09:12:36 -0400 Subject: [PATCH 5/9] tests: adds unit tests to cover AgentResponseEvent executor id mapping Signed-off-by: Vincent Biret --- .../AgentEventsTests.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentEventsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentEventsTests.cs index 2b8c4805d15..802d9da08c5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentEventsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentEventsTests.cs @@ -72,6 +72,35 @@ public void AgentResponseUpdateEvent_IsWorkflowOutputEvent() Assert.Same(update, evt.Data); } + /// + /// Verifies that AgentResponseUpdateEvent annotates updates with the authoritative workflow executor identifier. + /// + [Fact] + public void AgentResponseUpdateEvent_OverwritesExecutorIdAdditionalProperty() + { + // Arrange + const string ExecutorId = "executor1"; + const string ProviderExecutorId = "provider-controlled-executor"; + const string ExistingMetadataKey = "provider-metadata"; + AgentResponseUpdate update = new(ChatRole.Assistant, "test") + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + [ExistingMetadataKey] = "provider metadata", + [WorkflowAgentAdditionalProperties.ExecutorId] = ProviderExecutorId, + }, + }; + + // Act + AgentResponseUpdateEvent evt = new(ExecutorId, update); + + // Assert + Assert.Same(update, evt.Update); + Assert.NotNull(update.AdditionalProperties); + Assert.Equal("provider metadata", update.AdditionalProperties[ExistingMetadataKey]); + Assert.Equal(ExecutorId, update.AdditionalProperties[WorkflowAgentAdditionalProperties.ExecutorId]); + } + /// /// Verifies that AgentResponseEvent inherits from WorkflowOutputEvent. /// @@ -91,6 +120,35 @@ public void AgentResponseEvent_IsWorkflowOutputEvent() Assert.Same(response, evt.Data); } + /// + /// Verifies that AgentResponseEvent annotates responses with the authoritative workflow executor identifier. + /// + [Fact] + public void AgentResponseEvent_OverwritesExecutorIdAdditionalProperty() + { + // Arrange + const string ExecutorId = "executor1"; + const string ProviderExecutorId = "provider-controlled-executor"; + const string ExistingMetadataKey = "provider-metadata"; + AgentResponse response = new(new List { new(ChatRole.Assistant, "test") }) + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + [ExistingMetadataKey] = "provider metadata", + [WorkflowAgentAdditionalProperties.ExecutorId] = ProviderExecutorId, + }, + }; + + // Act + AgentResponseEvent evt = new(ExecutorId, response); + + // Assert + Assert.Same(response, evt.Response); + Assert.NotNull(response.AdditionalProperties); + Assert.Equal("provider metadata", response.AdditionalProperties[ExistingMetadataKey]); + Assert.Equal(ExecutorId, response.AdditionalProperties[WorkflowAgentAdditionalProperties.ExecutorId]); + } + /// /// Verifies that WorkflowStartedEvent is emitted first before any SuperStepStartedEvent. /// From f975eabcf6eb230afe9c51d1df66284e76f694d0 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 28 Aug 2026 10:08:56 -0400 Subject: [PATCH 6/9] feat: sets the executor id on the workflow session code path Signed-off-by: Vincent Biret --- .../WorkflowSession.cs | 31 ++++++--- .../WorkflowHostSmokeTests.cs | 69 +++++++++++++++++++ 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs index 97134a94073..7d44bee0d38 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs @@ -172,32 +172,41 @@ internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = nu return marshaller.Marshal(info); } - public AgentResponseUpdate CreateUpdate(string responseId, object raw, params AIContent[] parts) + public AgentResponseUpdate CreateUpdate(string responseId, object raw, string? executorId = default, params AIContent[] parts) { Throw.IfNullOrEmpty(parts); - return new(ChatRole.Assistant, parts) + return SetExecutorId(new(ChatRole.Assistant, parts) { CreatedAt = DateTimeOffset.UtcNow, MessageId = Guid.NewGuid().ToString("N"), Role = ChatRole.Assistant, ResponseId = responseId, RawRepresentation = raw - }; + }, executorId); } - public AgentResponseUpdate CreateUpdate(string responseId, object raw, ChatMessage message) + public AgentResponseUpdate CreateUpdate(string responseId, object raw, ChatMessage message, string? executorId = default) { Throw.IfNull(message); - return new(message.Role, message.Contents) + return SetExecutorId(new(message.Role, message.Contents) { AuthorName = message.AuthorName, CreatedAt = message.CreatedAt ?? DateTimeOffset.UtcNow, MessageId = message.MessageId ?? Guid.NewGuid().ToString("N"), ResponseId = responseId, RawRepresentation = raw - }; + }, executorId); + } + private static AgentResponseUpdate SetExecutorId(AgentResponseUpdate update, string? executorId) + { + if (!string.IsNullOrEmpty(executorId)) + { + update.AdditionalProperties ??= []; + update.AdditionalProperties[WorkflowAgentAdditionalProperties.ExecutorId] = executorId; + } + return update; } private async ValueTask CreateOrResumeRunAsync(List messages, CancellationToken cancellationToken = default) @@ -531,7 +540,7 @@ AgentResponseUpdate CreateObservabilityUpdate(WorkflowEvent evt) // External callers respond using the workflow-facing request ID, which is always RequestId. this.AddPendingRequest(requestInfo.Request.RequestId, requestInfo.Request); - AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, requestContent); + AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, parts: requestContent); yield return update; break; @@ -549,7 +558,7 @@ AgentResponseUpdate CreateObservabilityUpdate(WorkflowEvent evt) : "An error occurred while executing the workflow."; ErrorContent errorContent = new(message); - yield return this.CreateUpdate(this.LastResponseId, evt, errorContent); + yield return this.CreateUpdate(this.LastResponseId, evt, parts: errorContent); } break; @@ -570,7 +579,7 @@ AgentResponseUpdate CreateObservabilityUpdate(WorkflowEvent evt) ? executorException.Message : "An error occurred while executing the workflow."; - AgentResponseUpdate executorUpdate = this.CreateUpdate(this.LastResponseId, evt, new ErrorContent(executorMessage)); + AgentResponseUpdate executorUpdate = this.CreateUpdate(this.LastResponseId, evt, executorFailed.ExecutorId, new ErrorContent(executorMessage)); yield return executorUpdate; break; @@ -610,7 +619,7 @@ AgentResponseUpdate CreateObservabilityUpdate(WorkflowEvent evt) } emittedMessage = true; - yield return this.CreateUpdate(this.LastResponseId, evt, message); + yield return this.CreateUpdate(this.LastResponseId, evt, message, agentResponse.ExecutorId); } if (!emittedMessage && suppressedStreamedMessage) { @@ -663,7 +672,7 @@ AgentResponseUpdate CreateObservabilityUpdate(WorkflowEvent evt) AIContent[] contents = [.. updateContents]; if (contents.Length > 0) { - yield return this.CreateUpdate(this.LastResponseId, evt, contents); + yield return this.CreateUpdate(this.LastResponseId, evt, parts: contents); } } break; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index a2f2973dfe9..7c1d7d85153 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -1215,6 +1215,47 @@ public async Task Test_WorkflowHostAgent_UnstreamedMessageFromSameResponseIsForw .Should().BeTrue("only the correlated message in a multi-message response should be suppressed"); } + [Fact] + public async Task Test_WorkflowHostAgent_AgentResponseEventMessagesIncludeWorkflowExecutorIdAdditionalPropertyAsync() + { + using Futures.FuturesScope _ = new(enabled: false); + const string ExecutorId = "response-only-executor"; + Workflow workflow = new WorkflowBuilder(new ResponseOnlyExecutor(ExecutorId)).Build(); + + List updates = + await RunStreamingAsync(workflow, includeWorkflowOutputsInResponse: true); + + AgentResponseUpdate update = updates.Should().ContainSingle(u => u.Text == FinalText).Subject; + update.RawRepresentation.Should().BeOfType(); + update.AdditionalProperties.Should().NotBeNull(); + update.AdditionalProperties.Should().ContainKey(WorkflowAgentAdditionalProperties.ExecutorId) + .WhoseValue.Should().Be(ExecutorId); + } + + [Fact] + public async Task Test_WorkflowHostAgent_AgentResponseEventObservabilityUpdateIncludesWorkflowExecutorIdAdditionalPropertyAsync() + { + using Futures.FuturesScope _ = new(enabled: false); + const string ExecutorId = "stream-then-complete"; + const string MessageId = "shared-message"; + Workflow workflow = + new WorkflowBuilder( + new StreamThenCompleteExecutor( + useSameResponseId: true, + streamedMessageId: MessageId, + completedMessageId: MessageId)) + .Build(); + + List updates = + await RunStreamingAsync(workflow, includeWorkflowOutputsInResponse: true); + + AgentResponseUpdate update = updates.Should().ContainSingle(u => + u.MessageId == MessageId).Subject; + update.AdditionalProperties.Should().NotBeNull(); + update.AdditionalProperties.Should().ContainKey(WorkflowAgentAdditionalProperties.ExecutorId) + .WhoseValue.Should().Be(ExecutorId); + } + private sealed class StreamThenCompleteExecutor( bool useSameResponseId = false, string streamedMessageId = "streamed-message", @@ -1304,5 +1345,33 @@ await context.AddEventAsync( return new AgentResponse([streamedMessage, completedOnlyMessage]) { ResponseId = ResponseId }; } } + + private sealed class ResponseOnlyExecutor(string id) : Executor(id) + { + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => + protocolBuilder.ConfigureRoutes( + routeBuilder => + routeBuilder + .AddHandler>(this.HandleMessagesAsync) + .AddHandler(this.HandleTurnAsync)); + + private ValueTask HandleMessagesAsync( + IEnumerable messages, + IWorkflowContext context, + CancellationToken cancellationToken) => default; + + private ValueTask HandleTurnAsync( + TurnToken turnToken, + IWorkflowContext context, + CancellationToken cancellationToken) + { + ChatMessage message = new(ChatRole.Assistant, FinalText) + { + MessageId = "response-only-message", + }; + + return new(new AgentResponse([message]) { ResponseId = "response-only-response" }); + } + } } } From edf6435aed0229008d240a807da8e8b90d418997 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 28 Aug 2026 10:18:45 -0400 Subject: [PATCH 7/9] chore: revert changes on failure paths Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs index 7d44bee0d38..0dcf37c38cc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs @@ -579,7 +579,7 @@ AgentResponseUpdate CreateObservabilityUpdate(WorkflowEvent evt) ? executorException.Message : "An error occurred while executing the workflow."; - AgentResponseUpdate executorUpdate = this.CreateUpdate(this.LastResponseId, evt, executorFailed.ExecutorId, new ErrorContent(executorMessage)); + AgentResponseUpdate executorUpdate = this.CreateUpdate(this.LastResponseId, evt, parts: new ErrorContent(executorMessage)); yield return executorUpdate; break; From 70b38a45608a251c72732ad769fa991ddc8b7eaf Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 28 Aug 2026 10:21:24 -0400 Subject: [PATCH 8/9] chore: reverts executorId on parts code path since it's never used Signed-off-by: Vincent Biret --- .../WorkflowSession.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs index 0dcf37c38cc..eb134dc45bb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs @@ -172,18 +172,18 @@ internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = nu return marshaller.Marshal(info); } - public AgentResponseUpdate CreateUpdate(string responseId, object raw, string? executorId = default, params AIContent[] parts) + public AgentResponseUpdate CreateUpdate(string responseId, object raw, params AIContent[] parts) { Throw.IfNullOrEmpty(parts); - return SetExecutorId(new(ChatRole.Assistant, parts) + return new(ChatRole.Assistant, parts) { CreatedAt = DateTimeOffset.UtcNow, MessageId = Guid.NewGuid().ToString("N"), Role = ChatRole.Assistant, ResponseId = responseId, RawRepresentation = raw - }, executorId); + }; } public AgentResponseUpdate CreateUpdate(string responseId, object raw, ChatMessage message, string? executorId = default) @@ -540,7 +540,7 @@ AgentResponseUpdate CreateObservabilityUpdate(WorkflowEvent evt) // External callers respond using the workflow-facing request ID, which is always RequestId. this.AddPendingRequest(requestInfo.Request.RequestId, requestInfo.Request); - AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, parts: requestContent); + AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, requestContent); yield return update; break; @@ -558,7 +558,7 @@ AgentResponseUpdate CreateObservabilityUpdate(WorkflowEvent evt) : "An error occurred while executing the workflow."; ErrorContent errorContent = new(message); - yield return this.CreateUpdate(this.LastResponseId, evt, parts: errorContent); + yield return this.CreateUpdate(this.LastResponseId, evt, errorContent); } break; @@ -579,7 +579,7 @@ AgentResponseUpdate CreateObservabilityUpdate(WorkflowEvent evt) ? executorException.Message : "An error occurred while executing the workflow."; - AgentResponseUpdate executorUpdate = this.CreateUpdate(this.LastResponseId, evt, parts: new ErrorContent(executorMessage)); + AgentResponseUpdate executorUpdate = this.CreateUpdate(this.LastResponseId, evt, new ErrorContent(executorMessage)); yield return executorUpdate; break; @@ -672,7 +672,7 @@ AgentResponseUpdate CreateObservabilityUpdate(WorkflowEvent evt) AIContent[] contents = [.. updateContents]; if (contents.Length > 0) { - yield return this.CreateUpdate(this.LastResponseId, evt, parts: contents); + yield return this.CreateUpdate(this.LastResponseId, evt, contents); } } break; From acffd277df7db5a75fdbc5637ed89749968b787f Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 28 Aug 2026 10:27:57 -0400 Subject: [PATCH 9/9] feat: adds executor id additional property on observability update Signed-off-by: Vincent Biret --- .../Microsoft.Agents.AI.Workflows/WorkflowSession.cs | 10 +++++----- .../WorkflowHostSmokeTests.cs | 7 ++++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs index eb134dc45bb..3f9539e4376 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs @@ -508,15 +508,15 @@ IAsyncEnumerable InvokeStageAsync( await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); } - AgentResponseUpdate CreateObservabilityUpdate(WorkflowEvent evt) - => new(ChatRole.Assistant, []) + AgentResponseUpdate CreateObservabilityUpdate(WorkflowEvent evt, string? executorId = default) + => SetExecutorId(new(ChatRole.Assistant, []) { CreatedAt = DateTimeOffset.UtcNow, MessageId = Guid.NewGuid().ToString("N"), Role = ChatRole.Assistant, ResponseId = this.LastResponseId, RawRepresentation = evt - }; + }, executorId); await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken) .ConfigureAwait(false) @@ -595,7 +595,7 @@ AgentResponseUpdate CreateObservabilityUpdate(WorkflowEvent evt) // the legacy default, keep today's behavior — gated by the include flag. if (!Futures.EnableAgentResponseOutputTaggingAndFiltering && !this._includeWorkflowOutputsInResponse) { - yield return CreateObservabilityUpdate(evt); + yield return CreateObservabilityUpdate(evt, agentResponse.ExecutorId); break; } @@ -625,7 +625,7 @@ AgentResponseUpdate CreateObservabilityUpdate(WorkflowEvent evt) { // Preserve the completion event for observability after its correlated // streamed content has already been forwarded. - yield return CreateObservabilityUpdate(evt); + yield return CreateObservabilityUpdate(evt, agentResponse.ExecutorId); } break; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index 7c1d7d85153..197e76197b4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -1249,8 +1249,13 @@ public async Task Test_WorkflowHostAgent_AgentResponseEventObservabilityUpdateIn List updates = await RunStreamingAsync(workflow, includeWorkflowOutputsInResponse: true); + updates.Count(u => u.Text == FinalText).Should().Be(0, + "the completed response message should be suppressed after the matching message was already streamed"); + AgentResponseUpdate update = updates.Should().ContainSingle(u => - u.MessageId == MessageId).Subject; + u.RawRepresentation is AgentResponseEvent && u.Contents.Count == 0).Subject; + update.MessageId.Should().NotBe(MessageId, + "the observability update should be distinct from the already-streamed content update"); update.AdditionalProperties.Should().NotBeNull(); update.AdditionalProperties.Should().ContainKey(WorkflowAgentAdditionalProperties.ExecutorId) .WhoseValue.Should().Be(ExecutorId);