From 59e24d8ddb22f2f1ba55ffed13f249446bef84d8 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Mon, 3 Aug 2026 09:42:19 -0700 Subject: [PATCH 1/2] Add failing tests for workflow status/respond JSON response contract The workflow status and respond endpoints always write JSON on success but route every failure through CreateErrorResponseAsync without passing acceptsJson, so they fall back to AcceptsJson(req). That helper requires an explicit Accept header naming application/json, which means a client that sends no Accept header gets JSON on 200/202 and text/plain on 400/404. These tests assert JSON on both the success and error paths for all eight error call sites, across Accept: absent, text/plain, application/json, and */*. They fail today for every value except application/json. RespondToWorkflowAsync_MalformedBody also fails for application/json. The catch (JsonException) around ReadFromJsonAsync never fires: the worker's HttpRequestDataExtensions.ReadFromJsonAsync ends in ContinueWith(t => TryCast(t.Result)), and t.Result on a faulted task throws AggregateException. Malformed bodies surface as an unhandled 500 instead of the intended 400. Relates to #56 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 062445ed-7aad-4643-87b4-d1bc7313364a --- ...iltInFunctionsWorkflowJsonEndpointTests.cs | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/BuiltInFunctionsWorkflowJsonEndpointTests.cs 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); + } +} From 2519746c93166d4286a5e1aa7a7a828d38294956 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Mon, 3 Aug 2026 10:07:01 -0700 Subject: [PATCH 2/2] Always return JSON from workflow status and respond endpoints Both endpoints write JSON unconditionally on success but routed every failure through CreateErrorResponseAsync without an acceptsJson argument. That falls back to AcceptsJson(req), which requires an explicit Accept header naming application/json, so a client sending no Accept header received JSON on 200/202 and text/plain on 400/404. The response shape flipped within a single endpoint, precisely when the caller was trying to determine what went wrong. Neither endpoint has a plain-text representation, so both now force JSON at all eight error call sites rather than negotiating. This differs from the run endpoint, which keeps ShouldReturnWorkflowJson because it does have a meaningful text form. Also fixes malformed request bodies on the respond endpoint. The worker's HttpRequestDataExtensions.ReadFromJsonAsync ends in ContinueWith(t => TryCast(t.Result)), and Task.Result on a faulted task throws AggregateException, so catch (JsonException) never fired and a bad body surfaced as an unhandled 500 instead of the intended 400. Fixes #56 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 062445ed-7aad-4643-87b4-d1bc7313364a --- .../BuiltInFunctions.cs | 28 +++++++++++++------ .../CHANGELOG.md | 1 + 2 files changed, 20 insertions(+), 9 deletions(-) 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))