diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs index f79e074..968176b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs @@ -156,6 +156,10 @@ public static async Task RunWorkflowOrchestrationHttpTriggerAs /// Returns the workflow status including any pending HITL requests. /// The run ID is extracted from the route parameter {runId}. /// + /// + /// This endpoint has no plain-text representation, so it always responds with JSON on both + /// success and failure regardless of the request's Accept header. + /// public static async Task GetWorkflowStatusAsync( [HttpTrigger] HttpRequestData req, [DurableClient] DurableTaskClient client, @@ -164,13 +168,13 @@ public static async Task GetWorkflowStatusAsync( string? runId = context.BindingContext.BindingData.TryGetValue("runId", out object? value) ? value?.ToString() : null; if (string.IsNullOrEmpty(runId)) { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required."); + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required.", acceptsJson: true); } OrchestrationMetadata? metadata = await client.GetInstanceAsync(runId, getInputsAndOutputs: true); if (metadata is null || !IsOrchestrationOwnedByWorkflow(metadata.Name, context.FunctionDefinition.Name, StatusFunctionSuffix)) { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found."); + return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found.", acceptsJson: true); } // Parse HITL inputs the workflow is waiting for from the durable workflow status @@ -195,6 +199,10 @@ await response.WriteAsJsonAsync(new /// Sends a response to a pending RequestPort, resuming the workflow. /// Expects a JSON body: { "eventName": "...", "response": { ... } }. /// + /// + /// This endpoint has no plain-text representation, so it always responds with JSON on both + /// success and failure regardless of the request's Accept header. + /// public static async Task RespondToWorkflowAsync( [HttpTrigger] HttpRequestData req, [DurableClient] DurableTaskClient client, @@ -203,7 +211,7 @@ public static async Task RespondToWorkflowAsync( string? runId = context.BindingContext.BindingData.TryGetValue("runId", out object? value) ? value?.ToString() : null; if (string.IsNullOrEmpty(runId)) { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required."); + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required.", acceptsJson: true); } WorkflowRespondRequest? request; @@ -211,22 +219,24 @@ public static async Task RespondToWorkflowAsync( { request = await req.ReadFromJsonAsync(context.CancellationToken); } - catch (JsonException) + catch (Exception ex) when (ex is JsonException or AggregateException { InnerException: JsonException }) { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Request body is not valid JSON."); + // ReadFromJsonAsync surfaces deserialization failures through a ContinueWith that reads + // Task.Result, so a malformed body arrives wrapped in an AggregateException. + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Request body is not valid JSON.", acceptsJson: true); } if (request is null || string.IsNullOrEmpty(request.EventName) || request.Response.ValueKind == JsonValueKind.Undefined) { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Body must contain a non-empty 'eventName' and a 'response' property."); + return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Body must contain a non-empty 'eventName' and a 'response' property.", acceptsJson: true); } // Verify the orchestration exists and is in a valid state OrchestrationMetadata? metadata = await client.GetInstanceAsync(runId, getInputsAndOutputs: true); if (metadata is null || !IsOrchestrationOwnedByWorkflow(metadata.Name, context.FunctionDefinition.Name, RespondFunctionSuffix)) { - return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found."); + return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found.", acceptsJson: true); } if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Completed @@ -234,7 +244,7 @@ or OrchestrationRuntimeStatus.Failed or OrchestrationRuntimeStatus.Terminated) { return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, - $"Workflow run '{runId}' is in terminal state '{metadata.RuntimeStatus}'."); + $"Workflow run '{runId}' is in terminal state '{metadata.RuntimeStatus}'.", acceptsJson: true); } // Verify the workflow is waiting for the specified event. @@ -246,7 +256,7 @@ or OrchestrationRuntimeStatus.Failed if (!liveStatus.PendingEvents.Exists(p => string.Equals(p.EventName, request.EventName, StringComparison.Ordinal))) { return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, - $"Workflow is not waiting for event '{request.EventName}'."); + $"Workflow is not waiting for event '{request.EventName}'.", acceptsJson: true); } eventValidated = true; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md index 7f27f09..412b444 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- [BREAKING] Always return JSON from the workflow status and respond endpoints, including on errors and when the request sends no `Accept` header, and fix malformed request bodies surfacing as an unhandled error instead of `400 Bad Request` ([#60](https://github.com/microsoft/agent-framework-durable-extension/pull/60)) - [BREAKING] Support bounded synchronous workflow HTTP invocation through query parameters and default workflow run responses to JSON, with `Accept: text/plain` available for the legacy text format and the same negotiated asynchronous response returned on timeout ([#52](https://github.com/microsoft/agent-framework-durable-extension/pull/52)) - Added `DurableTaskClient.AsWorkflowClient` so functions can invoke durable workflows without constructing an `HttpClient` ([#48](https://github.com/microsoft/agent-framework-durable-extension/pull/48)) - [BREAKING] Consolidated the `AddWorkflow` extension overloads into a single method with optional `enableStatusEndpoint` and `enableMcpToolTrigger` parameters, and changed it to return `DurableWorkflowOptions` instead of `void` so multiple workflows can be registered fluently ([#39](https://github.com/microsoft/agent-framework-durable-extension/pull/39)) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/BuiltInFunctionsWorkflowJsonEndpointTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/BuiltInFunctionsWorkflowJsonEndpointTests.cs new file mode 100644 index 0000000..155596e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/BuiltInFunctionsWorkflowJsonEndpointTests.cs @@ -0,0 +1,291 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net; +using System.Text; +using System.Text.Json; +using Azure.Core.Serialization; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.DurableTask.Client; +using Microsoft.Extensions.Options; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests; + +/// +/// Verifies that the workflow status and respond endpoints, which have no plain-text +/// representation on success, also return JSON on failure regardless of the request's +/// Accept header. +/// +public sealed class BuiltInFunctionsWorkflowJsonEndpointTests +{ + private const string OrchestrationName = "dafx-TestWorkflow"; + private const string StatusFunctionName = "http-TestWorkflow-status"; + private const string RespondFunctionName = "http-TestWorkflow-respond"; + private const string RunId = "workflow-123"; + private const string EventName = "ApprovalPort"; + private const string ValidRespondBody = @"{""eventName"":""" + EventName + @""",""response"":{""approved"":true}}"; + + public static TheoryData AcceptHeaders => new(null, "text/plain", "application/json", "*/*"); + + [Theory] + [MemberData(nameof(AcceptHeaders))] + public async Task GetWorkflowStatusAsync_MissingRunId_ReturnsJsonErrorAsync(string? accept) + { + (HttpRequestData request, FunctionContext context) = CreateRequest(StatusFunctionName, runId: null, accept); + + HttpResponseData response = await BuiltInFunctions.GetWorkflowStatusAsync( + request, new Mock("test").Object, context); + + AssertJsonError(response, HttpStatusCode.BadRequest, "Run ID is required."); + } + + [Theory] + [MemberData(nameof(AcceptHeaders))] + public async Task GetWorkflowStatusAsync_UnknownRun_ReturnsJsonErrorAsync(string? accept) + { + (HttpRequestData request, FunctionContext context) = CreateRequest(StatusFunctionName, RunId, accept); + + HttpResponseData response = await BuiltInFunctions.GetWorkflowStatusAsync( + request, CreateClient(metadata: null).Object, context); + + AssertJsonError(response, HttpStatusCode.NotFound, $"Workflow run '{RunId}' not found."); + } + + [Theory] + [MemberData(nameof(AcceptHeaders))] + public async Task GetWorkflowStatusAsync_ExistingRun_ReturnsJsonAsync(string? accept) + { + (HttpRequestData request, FunctionContext context) = CreateRequest(StatusFunctionName, RunId, accept); + OrchestrationMetadata metadata = CreateMetadata(OrchestrationRuntimeStatus.Running, CreatePendingEventStatus()); + + HttpResponseData response = await BuiltInFunctions.GetWorkflowStatusAsync( + request, CreateClient(metadata).Object, context); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertJsonContentType(response); + using JsonDocument body = JsonDocument.Parse(GetResponseBody(response)); + Assert.Equal(RunId, body.RootElement.GetProperty("runId").GetString()); + Assert.Equal("Running", body.RootElement.GetProperty("status").GetString()); + JsonElement pending = Assert.Single(body.RootElement.GetProperty("waitingForInput").EnumerateArray()); + Assert.Equal(EventName, pending.GetProperty("eventName").GetString()); + } + + [Theory] + [MemberData(nameof(AcceptHeaders))] + public async Task RespondToWorkflowAsync_MissingRunId_ReturnsJsonErrorAsync(string? accept) + { + (HttpRequestData request, FunctionContext context) = CreateRequest(RespondFunctionName, runId: null, accept); + + HttpResponseData response = await BuiltInFunctions.RespondToWorkflowAsync( + request, new Mock("test").Object, context); + + AssertJsonError(response, HttpStatusCode.BadRequest, "Run ID is required."); + } + + [Theory] + [MemberData(nameof(AcceptHeaders))] + public async Task RespondToWorkflowAsync_MalformedBody_ReturnsJsonErrorAsync(string? accept) + { + (HttpRequestData request, FunctionContext context) = + CreateRequest(RespondFunctionName, RunId, accept, body: "{ not json"); + + HttpResponseData response = await BuiltInFunctions.RespondToWorkflowAsync( + request, new Mock("test").Object, context); + + AssertJsonError(response, HttpStatusCode.BadRequest, "Request body is not valid JSON."); + } + + [Theory] + [MemberData(nameof(AcceptHeaders))] + public async Task RespondToWorkflowAsync_MissingEventName_ReturnsJsonErrorAsync(string? accept) + { + (HttpRequestData request, FunctionContext context) = + CreateRequest(RespondFunctionName, RunId, accept, body: """{"response":{"approved":true}}"""); + + HttpResponseData response = await BuiltInFunctions.RespondToWorkflowAsync( + request, new Mock("test").Object, context); + + AssertJsonError( + response, + HttpStatusCode.BadRequest, + "Body must contain a non-empty 'eventName' and a 'response' property."); + } + + [Theory] + [MemberData(nameof(AcceptHeaders))] + public async Task RespondToWorkflowAsync_UnknownRun_ReturnsJsonErrorAsync(string? accept) + { + (HttpRequestData request, FunctionContext context) = + CreateRequest(RespondFunctionName, RunId, accept, body: ValidRespondBody); + + HttpResponseData response = await BuiltInFunctions.RespondToWorkflowAsync( + request, CreateClient(metadata: null).Object, context); + + AssertJsonError(response, HttpStatusCode.NotFound, $"Workflow run '{RunId}' not found."); + } + + [Theory] + [MemberData(nameof(AcceptHeaders))] + public async Task RespondToWorkflowAsync_TerminalRun_ReturnsJsonErrorAsync(string? accept) + { + (HttpRequestData request, FunctionContext context) = + CreateRequest(RespondFunctionName, RunId, accept, body: ValidRespondBody); + OrchestrationMetadata metadata = CreateMetadata(OrchestrationRuntimeStatus.Completed); + + HttpResponseData response = await BuiltInFunctions.RespondToWorkflowAsync( + request, CreateClient(metadata).Object, context); + + AssertJsonError( + response, + HttpStatusCode.BadRequest, + $"Workflow run '{RunId}' is in terminal state 'Completed'."); + } + + [Theory] + [MemberData(nameof(AcceptHeaders))] + public async Task RespondToWorkflowAsync_UnexpectedEvent_ReturnsJsonErrorAsync(string? accept) + { + (HttpRequestData request, FunctionContext context) = + CreateRequest(RespondFunctionName, RunId, accept, body: ValidRespondBody); + OrchestrationMetadata metadata = CreateMetadata( + OrchestrationRuntimeStatus.Running, + CreatePendingEventStatus("SomeOtherPort")); + + HttpResponseData response = await BuiltInFunctions.RespondToWorkflowAsync( + request, CreateClient(metadata).Object, context); + + AssertJsonError( + response, + HttpStatusCode.BadRequest, + $"Workflow is not waiting for event '{EventName}'."); + } + + [Theory] + [MemberData(nameof(AcceptHeaders))] + public async Task RespondToWorkflowAsync_PendingEvent_ReturnsJsonAsync(string? accept) + { + (HttpRequestData request, FunctionContext context) = + CreateRequest(RespondFunctionName, RunId, accept, body: ValidRespondBody); + OrchestrationMetadata metadata = CreateMetadata( + OrchestrationRuntimeStatus.Running, + CreatePendingEventStatus()); + Mock client = CreateClient(metadata); + client + .Setup(c => c.RaiseEventAsync(RunId, EventName, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + HttpResponseData response = await BuiltInFunctions.RespondToWorkflowAsync( + request, client.Object, context); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + AssertJsonContentType(response); + using JsonDocument body = JsonDocument.Parse(GetResponseBody(response)); + Assert.Equal(RunId, body.RootElement.GetProperty("runId").GetString()); + Assert.Equal(EventName, body.RootElement.GetProperty("eventName").GetString()); + Assert.True(body.RootElement.GetProperty("validated").GetBoolean()); + } + + private static void AssertJsonError(HttpResponseData response, HttpStatusCode expectedStatus, string expectedError) + { + Assert.Equal(expectedStatus, response.StatusCode); + AssertJsonContentType(response); + using JsonDocument body = JsonDocument.Parse(GetResponseBody(response)); + Assert.Equal((int)expectedStatus, body.RootElement.GetProperty("status").GetInt32()); + Assert.Equal(expectedError, body.RootElement.GetProperty("error").GetString()); + } + + private static void AssertJsonContentType(HttpResponseData response) + { + Assert.True(response.Headers.TryGetValues("Content-Type", out IEnumerable? contentTypes)); + Assert.Contains("application/json", Assert.Single(contentTypes), StringComparison.OrdinalIgnoreCase); + } + + private static string GetResponseBody(HttpResponseData response) => + Encoding.UTF8.GetString(((MemoryStream)response.Body).ToArray()); + + private static string CreatePendingEventStatus(string eventName = EventName) => + JsonSerializer.Serialize(new + { + pendingEvents = new[] { new { eventName, input = """{"amount":100}""" } }, + }); + + private static OrchestrationMetadata CreateMetadata( + OrchestrationRuntimeStatus runtimeStatus, + string? serializedCustomStatus = null) => + new(OrchestrationName, RunId) + { + RuntimeStatus = runtimeStatus, + DataConverter = Microsoft.DurableTask.Converters.JsonDataConverter.Default, + SerializedCustomStatus = serializedCustomStatus, + }; + + private static Mock CreateClient(OrchestrationMetadata? metadata) + { + Mock client = new("test"); + client + .Setup(c => c.GetInstanceAsync(RunId, true, It.IsAny())) + .ReturnsAsync(metadata); + return client; + } + + private static (HttpRequestData Request, FunctionContext Context) CreateRequest( + string functionName, + string? runId, + string? accept, + string? body = null) + { + Mock serializer = new(); + serializer + .Setup(s => s.SerializeAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (stream, value, type, token) => + await JsonSerializer.SerializeAsync(stream, value, type, cancellationToken: token)); + serializer + .Setup(s => s.DeserializeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((stream, type, token) => + JsonSerializer.DeserializeAsync(stream, type, cancellationToken: token)); + + WorkerOptions workerOptions = new() { Serializer = serializer.Object }; + Mock> options = new(); + options.SetupGet(o => o.Value).Returns(workerOptions); + Mock services = new(); + services.Setup(s => s.GetService(typeof(IOptions))).Returns(options.Object); + + Dictionary bindingData = new(StringComparer.OrdinalIgnoreCase); + if (runId is not null) + { + bindingData["runId"] = runId; + } + + Mock bindingContext = new(); + bindingContext.SetupGet(b => b.BindingData).Returns(bindingData); + + Mock functionDefinition = new(); + functionDefinition.SetupGet(d => d.Name).Returns(functionName); + + Mock context = new(); + context.SetupGet(c => c.CancellationToken).Returns(CancellationToken.None); + context.SetupGet(c => c.InstanceServices).Returns(services.Object); + context.SetupGet(c => c.BindingContext).Returns(bindingContext.Object); + context.SetupGet(c => c.FunctionDefinition).Returns(functionDefinition.Object); + + Mock response = new(context.Object); + response.SetupProperty(r => r.StatusCode, HttpStatusCode.OK); + response.SetupProperty(r => r.Body, new MemoryStream()); + response.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection()); + + HttpHeadersCollection requestHeaders = new(); + if (accept is not null) + { + Assert.True(requestHeaders.TryAddWithoutValidation("Accept", accept)); + } + + Mock request = new(context.Object); + request.SetupGet(r => r.Headers).Returns(requestHeaders); + request.SetupGet(r => r.Body).Returns(new MemoryStream(Encoding.UTF8.GetBytes(body ?? string.Empty))); + request.Setup(r => r.CreateResponse()).Returns(response.Object); + + return (request.Object, context.Object); + } +}