From 345f6165ca35f8bdd26a320062f4c66e0d64dc51 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Fri, 27 Feb 2026 14:38:36 -0800 Subject: [PATCH 1/2] Handle external input request and response conversion for workflow as agent scenario --- .../WorkflowSession.cs | 133 +++++++++++- .../WorkflowHostSmokeTests.cs | 198 ++++++++++++++++++ 2 files changed, 328 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs index 40a18dbadb..a045c6de8a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs @@ -25,6 +25,10 @@ internal sealed class WorkflowSession : AgentSession private InMemoryCheckpointManager? _inMemoryCheckpointManager; + // Key prefix used in StateBag to track pending external requests by their content ID + // This enables converting incoming response content back to ExternalResponse when resuming. + private const string PendingRequestKeyPrefix = "workflow.pendingRequest:"; + internal static bool VerifyCheckpointingConfiguration(IWorkflowExecutionEnvironment executionEnvironment, [NotNullWhen(true)] out InProcessExecutionEnvironment? inProcEnv) { inProcEnv = null; @@ -154,7 +158,8 @@ await this._executionEnvironment cancellationToken) .ConfigureAwait(false); - await run.TrySendMessageAsync(messages).ConfigureAwait(false); + // Process messages: convert response content to ExternalResponse, send regular messages as-is + await this.SendMessagesWithResponseConversionAsync(run, messages, cancellationToken).ConfigureAwait(false); return run; } @@ -166,6 +171,116 @@ await this._executionEnvironment .ConfigureAwait(false); } + /// + /// Sends messages to the run, converting FunctionResultContent and UserInputResponseContent + /// to ExternalResponse when there's a matching pending request. + /// + private async ValueTask SendMessagesWithResponseConversionAsync(StreamingRun run, List messages, CancellationToken cancellationToken) + { + List regularMessages = []; + + foreach (ChatMessage message in messages) + { + List regularContents = []; + + foreach (AIContent content in message.Contents) + { + if (this.TryCreateExternalResponse(content) is ExternalResponse response) + { + await run.SendResponseAsync(response).ConfigureAwait(false); + + if (GetResponseContentId(content) is string contentId) + { + this.RemovePendingRequest(contentId); + } + } + else + { + regularContents.Add(content); + } + } + + if (regularContents.Count > 0) + { + regularMessages.Add(new ChatMessage(message.Role, regularContents) + { + AuthorName = message.AuthorName, + MessageId = message.MessageId, + CreatedAt = message.CreatedAt + }); + } + } + + // Send any remaining regular messages + if (regularMessages.Count > 0) + { + await run.TrySendMessageAsync(regularMessages).ConfigureAwait(false); + } + } + + /// + /// Attempts to create an ExternalResponse from response content (FunctionResultContent or UserInputResponseContent) + /// by matching it to a pending request. + /// + private ExternalResponse? TryCreateExternalResponse(AIContent content) + { + string? contentId = GetResponseContentId(content); + if (contentId == null) + { + return null; + } + + ExternalRequest? pendingRequest = this.TryGetPendingRequest(contentId); + if (pendingRequest == null) + { + return null; + } + + // Create the response data based on content type + object? responseData = content switch + { + FunctionResultContent functionResultContent => functionResultContent, + UserInputResponseContent userInputResponseContent => userInputResponseContent, + _ => null + }; + + if (responseData == null) + { + return null; + } + + // Create ExternalResponse using the pending request's port info + return new ExternalResponse(pendingRequest.PortInfo, pendingRequest.RequestId, new PortableValue(responseData)); + } + + /// + /// Gets the content ID from response content types. + /// + private static string? GetResponseContentId(AIContent content) => content switch + { + FunctionResultContent functionResultContent => functionResultContent.CallId, + UserInputResponseContent userInputResponseContent => userInputResponseContent.Id, + _ => null + }; + + /// + /// Tries to get a pending request from the state bag by content ID. + /// + private ExternalRequest? TryGetPendingRequest(string contentId) => + this.StateBag.GetValue(PendingRequestKeyPrefix + contentId); + + /// + /// Adds a pending request to the state bag. + /// + private void AddPendingRequest(string contentId, ExternalRequest request) => + this.StateBag.SetValue(PendingRequestKeyPrefix + contentId, request); + + /// + /// Removes a pending request from the state bag. + /// + private void RemovePendingRequest(string contentId) => + this.StateBag.TryRemoveValue(PendingRequestKeyPrefix + contentId); + internal async IAsyncEnumerable InvokeStageAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default) @@ -192,8 +307,20 @@ IAsyncEnumerable InvokeStageAsync( break; case RequestInfoEvent requestInfo: - FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall(); - AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, fcContent); + (AIContent requestContent, string? contentId) = requestInfo.Request switch + { + ExternalRequest externalRequest when externalRequest.TryGetDataAs(out FunctionCallContent? fcc) => (fcc, fcc.CallId), + ExternalRequest externalRequest when externalRequest.TryGetDataAs(out UserInputRequestContent? uic) => (uic, uic.Id), + ExternalRequest externalRequest => ((AIContent)externalRequest.ToFunctionCall(), externalRequest.RequestId) + }; + + // Track the pending request so we can convert incoming responses back to ExternalResponse + if (contentId != null) + { + this.AddPendingRequest(contentId, requestInfo.Request); + } + + AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, requestContent); yield return update; break; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index 40e4f2098f..f8184ee2c9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -28,6 +28,43 @@ public ExpectedException(string? message, Exception? innerException) : base(mess } } +/// +/// A simple agent that emits a FunctionCallContent or UserInputRequestContent request. +/// Used to test that RequestInfoEvent handling preserves the original content type. +/// +internal sealed class RequestEmittingAgent : AIAgent +{ + private readonly AIContent _requestContent; + + public RequestEmittingAgent(AIContent requestContent) + { + this._requestContent = requestContent; + } + + private sealed class Session : AgentSession + { + public Session() { } + } + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => new(new Session()); + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new Session()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => default; + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + => this.RunStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken); + + protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Emit the request content + yield return new AgentResponseUpdate(ChatRole.Assistant, [this._requestContent]); + } +} + public class WorkflowHostSmokeTests { private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent @@ -112,4 +149,165 @@ public async Task Test_AsAgent_ErrorContentStreamedOutAsync(bool includeExceptio hadErrorContent.Should().BeTrue(); } + + /// + /// Tests that when a workflow emits a RequestInfoEvent with FunctionCallContent data, + /// the AgentResponseUpdate preserves the original FunctionCallContent type. + /// Regression test for GitHub issue #3029. + /// + [Fact] + public async Task Test_AsAgent_FunctionCallContentPreservedInRequestInfoAsync() + { + // Arrange + const string CallId = "test-call-id"; + const string FunctionName = "testFunction"; + FunctionCallContent originalContent = new(CallId, FunctionName); + RequestEmittingAgent requestAgent = new(originalContent); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + + // Act + List updates = await workflow.AsAIAgent("WorkflowAgent") + .RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello")) + .ToListAsync(); + + // Assert + AgentResponseUpdate? updateWithFunctionCall = updates.FirstOrDefault(u => + u.Contents.Any(c => c is FunctionCallContent)); + + updateWithFunctionCall.Should().NotBeNull("a FunctionCallContent should be present in the response updates"); + FunctionCallContent retrievedContent = updateWithFunctionCall!.Contents + .OfType() + .Should().ContainSingle() + .Which; + + retrievedContent.CallId.Should().Be(CallId); + retrievedContent.Name.Should().Be(FunctionName); + } + + /// + /// Tests that when a workflow emits a RequestInfoEvent with UserInputRequestContent data, + /// the AgentResponseUpdate preserves the original UserInputRequestContent type. + /// Regression test for GitHub issue #3029. + /// + [Fact] + public async Task Test_AsAgent_UserInputRequestContentPreservedInRequestInfoAsync() + { + // Arrange + const string RequestId = "test-request-id"; + McpServerToolCallContent mcpCalll = new("call-id", "testToolName", "http://localhost"); + UserInputRequestContent originalContent = new McpServerToolApprovalRequestContent(RequestId, mcpCalll); + RequestEmittingAgent requestAgent = new(originalContent); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUserInputRequests = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + + // Act + List updates = await workflow.AsAIAgent("WorkflowAgent") + .RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello")) + .ToListAsync(); + + // Assert + AgentResponseUpdate? updateWithUserInput = updates.FirstOrDefault(u => + u.Contents.Any(c => c is UserInputRequestContent)); + + updateWithUserInput.Should().NotBeNull("a UserInputRequestContent should be present in the response updates"); + UserInputRequestContent retrievedContent = updateWithUserInput!.Contents + .OfType() + .Should().ContainSingle() + .Which; + + retrievedContent.Should().BeOfType(); + retrievedContent.Id.Should().Be(RequestId); + } + + /// + /// Tests the full roundtrip: workflow emits a request, external caller responds, workflow processes response. + /// + [Fact] + public async Task Test_AsAgent_FunctionCallRoundtrip_ResponseIsProcessedAsync() + { + // Arrange: Create an agent that emits a FunctionCallContent request + const string CallId = "roundtrip-call-id"; + const string FunctionName = "testFunction"; + FunctionCallContent requestContent = new(CallId, FunctionName); + RequestEmittingAgent requestAgent = new(requestContent); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + // Act 1: First call - should receive the FunctionCallContent request + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.User, "Start"), + session).ToListAsync(); + + // Assert 1: We should have received a FunctionCallContent + AgentResponseUpdate? updateWithRequest = firstCallUpdates.FirstOrDefault(u => + u.Contents.Any(c => c is FunctionCallContent)); + updateWithRequest.Should().NotBeNull("a FunctionCallContent should be present in the response updates"); + + FunctionCallContent receivedRequest = updateWithRequest!.Contents + .OfType() + .First(); + receivedRequest.CallId.Should().Be(CallId); + + // Act 2: Send the response back + FunctionResultContent responseContent = new(CallId, "test result"); + ChatMessage responseMessage = new(ChatRole.Tool, [responseContent]); + + // This should work without throwing - the response should be converted to ExternalResponse + // and processed by the workflow + Func sendResponse = () => agent.RunStreamingAsync(responseMessage, session).ToListAsync().AsTask(); + + // Assert 2: The response should be accepted without error + await sendResponse.Should().NotThrowAsync("the response should be converted to ExternalResponse and processed"); + } + + /// + /// Tests the full roundtrip for UserInputRequestContent: workflow emits request, external caller responds. + /// Verifying inbound UserInputResponseContent conversion. + /// + [Fact] + public async Task Test_AsAgent_UserInputRoundtrip_ResponseIsProcessedAsync() + { + // Arrange: Create an agent that emits a UserInputRequestContent request + const string RequestId = "roundtrip-request-id"; + McpServerToolCallContent mcpCall = new("mcp-call-id", "testMcpTool", "http://localhost"); + McpServerToolApprovalRequestContent requestContent = new(RequestId, mcpCall); + RequestEmittingAgent requestAgent = new(requestContent); + ExecutorBinding agentBinding = requestAgent.BindAsExecutor( + new AIAgentHostOptions { InterceptUserInputRequests = false, EmitAgentUpdateEvents = true }); + Workflow workflow = new WorkflowBuilder(agentBinding).Build(); + AIAgent agent = workflow.AsAIAgent("WorkflowAgent"); + + // Act 1: First call - should receive the UserInputRequestContent request + AgentSession session = await agent.CreateSessionAsync(); + List firstCallUpdates = await agent.RunStreamingAsync( + new ChatMessage(ChatRole.User, "Start"), + session).ToListAsync(); + + // Assert 1: We should have received a UserInputRequestContent + AgentResponseUpdate? updateWithRequest = firstCallUpdates.FirstOrDefault(u => + u.Contents.Any(c => c is UserInputRequestContent)); + updateWithRequest.Should().NotBeNull("a UserInputRequestContent should be present in the response updates"); + + UserInputRequestContent receivedRequest = updateWithRequest!.Contents + .OfType() + .First(); + receivedRequest.Id.Should().Be(RequestId); + + // Act 2: Send the response back - use CreateResponse to get the right response type + UserInputResponseContent responseContent = requestContent.CreateResponse(approved: true); + ChatMessage responseMessage = new(ChatRole.User, [responseContent]); + + // This should work without throwing - the response should be converted to ExternalResponse + // and processed by the workflow + Func sendResponse = () => agent.RunStreamingAsync(responseMessage, session).ToListAsync().AsTask(); + + // Assert 2: The response should be accepted without error + await sendResponse.Should().NotThrowAsync("the response should be converted to ExternalResponse and processed"); + } } From 9ed290d617ed70c6c9ae66590df8d2e2cf5696fe Mon Sep 17 00:00:00 2001 From: Peter Ibekwe Date: Fri, 27 Feb 2026 14:57:41 -0800 Subject: [PATCH 2/2] Remove unnecessary test comment --- .../WorkflowHostSmokeTests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index f8184ee2c9..8f1c5f1c89 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -153,7 +153,6 @@ public async Task Test_AsAgent_ErrorContentStreamedOutAsync(bool includeExceptio /// /// Tests that when a workflow emits a RequestInfoEvent with FunctionCallContent data, /// the AgentResponseUpdate preserves the original FunctionCallContent type. - /// Regression test for GitHub issue #3029. /// [Fact] public async Task Test_AsAgent_FunctionCallContentPreservedInRequestInfoAsync() @@ -189,7 +188,6 @@ public async Task Test_AsAgent_FunctionCallContentPreservedInRequestInfoAsync() /// /// Tests that when a workflow emits a RequestInfoEvent with UserInputRequestContent data, /// the AgentResponseUpdate preserves the original UserInputRequestContent type. - /// Regression test for GitHub issue #3029. /// [Fact] public async Task Test_AsAgent_UserInputRequestContentPreservedInRequestInfoAsync()