diff --git a/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md b/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md
index 4ef1c6a..93ed14f 100644
--- a/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md
+++ b/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md
@@ -52,10 +52,15 @@ Invoke-RestMethod -Method Post `
The response will confirm the workflow orchestration has started:
-```text
-Workflow orchestration started for CancelOrder. Orchestration runId: abc123def456
+```json
+{
+ "runId": "abc123def456",
+ "message": "Workflow orchestration started for CancelOrder."
+}
```
+Workflow run responses use JSON by default. Include `Accept: text/plain` to request the legacy plain-text representation instead.
+
> **Tip:** You can provide a custom run ID by appending a `runId` query parameter:
>
> ```bash
@@ -68,14 +73,13 @@ Workflow orchestration started for CancelOrder. Orchestration runId: abc123def45
### Wait for the Workflow Result
-By default, the HTTP endpoint returns `202 Accepted` immediately with the run ID. If you want to wait for the workflow to complete and get the result in the response, add the `x-ms-wait-for-response: true` header:
+By default, the HTTP endpoint returns `202 Accepted` immediately with the run ID. To wait for the workflow to complete, set `waitForResponse=true` in the query string. The endpoint waits for up to 10 seconds by default; set `timeoutSeconds` to use a timeout from 1 to 200 seconds. If the workflow is still running when the timeout expires, the endpoint returns the same `202 Accepted` response as the default asynchronous invocation.
Bash (Linux/macOS/WSL):
```bash
-curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \
+curl -X POST "http://localhost:7071/api/workflows/CancelOrder/run?waitForResponse=true&timeoutSeconds=30" \
-H "Content-Type: text/plain" \
- -H "x-ms-wait-for-response: true" \
-d "12345"
```
@@ -83,36 +87,36 @@ PowerShell:
```powershell
Invoke-RestMethod -Method Post `
- -Uri http://localhost:7071/api/workflows/CancelOrder/run `
+ -Uri "http://localhost:7071/api/workflows/CancelOrder/run?waitForResponse=true&timeoutSeconds=30" `
-ContentType text/plain `
- -Headers @{ "x-ms-wait-for-response" = "true" } `
-Body "12345"
```
-The response will contain the workflow result as plain text (200 OK):
+The response is JSON by default:
-```text
-Cancellation email sent for order 12345 to jerry@example.com.
+```json
+{
+ "runId": "abc123def456",
+ "workflowStatus": "Completed",
+ "result": "Cancellation email sent for order 12345 to jerry@example.com."
+}
```
-To get the result as JSON, also include the `Accept: application/json` header:
+To get only the workflow result as plain text, include the `Accept: text/plain` header:
```bash
-curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \
+curl -X POST "http://localhost:7071/api/workflows/CancelOrder/run?waitForResponse=true" \
-H "Content-Type: text/plain" \
- -H "x-ms-wait-for-response: true" \
- -H "Accept: application/json" \
+ -H "Accept: text/plain" \
-d "12345"
```
-```json
-{
- "runId": "abc123def456",
- "workflowStatus": "Completed",
- "result": "Cancellation email sent for order 12345 to jerry@example.com."
-}
+```text
+Cancellation email sent for order 12345 to jerry@example.com.
```
+The `x-ms-wait-for-response` header remains supported for backward compatibility. A wait timeout returns the same `202 Accepted` response as the default asynchronous invocation. Client request cancellation instead aborts the HTTP wait without returning a response, but the durable workflow continues; callers that need to recover should supply `runId` up front and query its status later.
+
In the function app logs, you will see the sequential execution of each executor:
```text
diff --git a/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http b/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http
index adbafad..95f52ea 100644
--- a/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http
+++ b/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http
@@ -7,18 +7,16 @@ Content-Type: text/plain
12345
-### Cancel an order and wait for the result
-POST {{authority}}/api/workflows/CancelOrder/run
+### Cancel an order and wait for the result (plain-text response)
+POST {{authority}}/api/workflows/CancelOrder/run?waitForResponse=true&timeoutSeconds=30
Content-Type: text/plain
-x-ms-wait-for-response: true
+Accept: text/plain
12345
-### Cancel an order and wait for the result (JSON response)
-POST {{authority}}/api/workflows/CancelOrder/run
+### Cancel an order and wait for the result (default JSON response)
+POST {{authority}}/api/workflows/CancelOrder/run?waitForResponse=true
Content-Type: text/plain
-Accept: application/json
-x-ms-wait-for-response: true
12345
@@ -35,9 +33,8 @@ Content-Type: text/plain
12345
### Get order status and wait for the result
-POST {{authority}}/api/workflows/OrderStatus/run
+POST {{authority}}/api/workflows/OrderStatus/run?waitForResponse=true
Content-Type: text/plain
-x-ms-wait-for-response: true
12345
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs
index a96932d..f79e074 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs
@@ -2,6 +2,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
+using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.DurableTask;
@@ -26,6 +27,28 @@ internal static class BuiltInFunctions
private const string WaitForResponseHeaderName = "x-ms-wait-for-response";
+ ///
+ /// Query parameter used by the agent endpoints, which use snake_case names throughout their
+ /// query string, request body, and response body.
+ ///
+ ///
+ /// Issue https://github.com/microsoft/agent-framework-durable-extension/issues/51 tracks the plan
+ /// to unify the query parameter naming across all endpoints.
+ ///
+ private const string AgentWaitForResponseParameterName = "wait_for_response";
+
+ ///
+ /// Query parameter used by the workflow endpoints, which use camelCase names throughout their
+ /// query string, request body, and response body.
+ ///
+ private const string WorkflowWaitForResponseParameterName = "waitForResponse";
+
+ private const string WorkflowRunIdParameterName = "runId";
+ private const int MaxWorkflowRunIdLength = 100;
+ private const string WaitTimeoutSecondsParameterName = "timeoutSeconds";
+ private const int DefaultWaitTimeoutSeconds = 10;
+ private const int MaxWaitTimeoutSeconds = 200;
+
private const string SessionIdHeaderName = "x-ms-session-id";
private const string SessionIdParameterName = "session_id";
private const string SessionIdMcpArgumentName = "sessionId";
@@ -73,24 +96,60 @@ public static async Task RunWorkflowOrchestrationHttpTriggerAs
if (string.IsNullOrEmpty(inputMessage))
{
- return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Workflow input cannot be empty.");
+ return await CreateErrorResponseAsync(
+ req,
+ context,
+ HttpStatusCode.BadRequest,
+ "Workflow input cannot be empty.",
+ ShouldReturnWorkflowJson(req));
+ }
+
+ if (!TryGetWorkflowWaitForResponse(req, out bool waitForResponse, out string? waitForResponseError))
+ {
+ return await CreateErrorResponseAsync(
+ req,
+ context,
+ HttpStatusCode.BadRequest,
+ waitForResponseError!,
+ ShouldReturnWorkflowJson(req));
+ }
+
+ TimeSpan waitTimeout = default;
+ if (waitForResponse && !TryGetWaitTimeout(req, out waitTimeout, out string? timeoutError))
+ {
+ return await CreateErrorResponseAsync(
+ req,
+ context,
+ HttpStatusCode.BadRequest,
+ timeoutError!,
+ ShouldReturnWorkflowJson(req));
+ }
+
+ string? instanceId = req.Query[WorkflowRunIdParameterName];
+ if (!TryValidateWorkflowRunId(instanceId, out string? runIdError))
+ {
+ return await CreateErrorResponseAsync(
+ req,
+ context,
+ HttpStatusCode.BadRequest,
+ runIdError!,
+ ShouldReturnWorkflowJson(req));
}
DurableWorkflowInput orchestrationInput = new() { Input = inputMessage };
// Allow users to provide a custom run ID via query string; otherwise, auto-generate one.
- string? instanceId = req.Query["runId"];
StartOrchestrationOptions? options = instanceId is not null ? new StartOrchestrationOptions(instanceId) : null;
string resolvedInstanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, orchestrationInput, options);
- if (ShouldWaitForResponse(req, defaultValue: false))
+ if (waitForResponse)
{
- return await WaitForWorkflowCompletionAsync(req, client, context, resolvedInstanceId);
+ return await WaitForWorkflowCompletionAsync(
+ req, client, context, workflowName, resolvedInstanceId, waitTimeout);
}
- HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
- await response.WriteStringAsync($"Workflow orchestration started for {workflowName}. Orchestration runId: {resolvedInstanceId}");
- return response;
+ return await CreateWorkflowAcceptedResponseAsync(
+ req, workflowName, resolvedInstanceId, context.CancellationToken);
}
///
@@ -298,7 +357,7 @@ public static async Task RunAgentHttpAsync(
message = await req.ReadAsStringAsync();
}
- // The session ID can come from the query string or the JSON body, under either the canonical
+ // The session ID can come from the query string or the request body, using either the canonical
// "session_id" name or the deprecated "thread_id" alias. Conflicting values are rejected.
if (!TryResolveSessionKey(
sessionIdFromBody,
@@ -329,7 +388,7 @@ public static async Task RunAgentHttpAsync(
}
// Check if we should wait for response (default is true)
- bool waitForResponse = ShouldWaitForResponse(req, defaultValue: true);
+ bool waitForResponse = ShouldWaitForResponse(req, AgentWaitForResponseParameterName, defaultValue: true);
AIAgent agentProxy = client.AsDurableAgentProxy(context, agentName);
@@ -462,23 +521,39 @@ await agentProxy.RunAsync(
///
/// Waits for a workflow orchestration to complete and returns an appropriate HTTP response.
///
- private static async Task WaitForWorkflowCompletionAsync(
+ internal static async Task WaitForWorkflowCompletionAsync(
HttpRequestData req,
DurableTaskClient client,
FunctionContext context,
- string instanceId)
+ string workflowName,
+ string instanceId,
+ TimeSpan timeout)
{
- bool acceptsJson = AcceptsJson(req);
+ bool returnJson = ShouldReturnWorkflowJson(req);
- OrchestrationMetadata? metadata = await client.WaitForInstanceCompletionAsync(
- instanceId,
- getInputsAndOutputs: true,
- cancellation: context.CancellationToken);
+ OrchestrationMetadata? metadata;
+ using (CancellationTokenSource timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken))
+ {
+ timeoutSource.CancelAfter(timeout);
+
+ try
+ {
+ metadata = await client.WaitForInstanceCompletionAsync(
+ instanceId,
+ getInputsAndOutputs: true,
+ cancellation: timeoutSource.Token);
+ }
+ catch (OperationCanceledException) when (!context.CancellationToken.IsCancellationRequested)
+ {
+ return await CreateWorkflowAcceptedResponseAsync(
+ req, workflowName, instanceId, context.CancellationToken);
+ }
+ }
if (metadata is null)
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound,
- $"No workflow orchestration with ID '{instanceId}' was found.", acceptsJson);
+ $"No workflow orchestration with ID '{instanceId}' was found.", returnJson);
}
if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Failed)
@@ -486,7 +561,7 @@ private static async Task WaitForWorkflowCompletionAsync(
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Unknown error";
HttpResponseData failedResponse = req.CreateResponse(HttpStatusCode.OK);
- if (acceptsJson)
+ if (returnJson)
{
await failedResponse.WriteAsJsonAsync(
new WorkflowRunResponse(instanceId, metadata.RuntimeStatus.ToString(), Result: null, Error: errorMessage),
@@ -504,14 +579,14 @@ await failedResponse.WriteAsJsonAsync(
if (metadata.RuntimeStatus is not OrchestrationRuntimeStatus.Completed)
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.InternalServerError,
- $"Workflow orchestration '{instanceId}' ended with unexpected status '{metadata.RuntimeStatus}'.", acceptsJson);
+ $"Workflow orchestration '{instanceId}' ended with unexpected status '{metadata.RuntimeStatus}'.", returnJson);
}
string? result = metadata.ReadOutputAs()?.Result;
HttpResponseData response = req.CreateResponse(HttpStatusCode.OK);
- if (acceptsJson)
+ if (returnJson)
{
JsonElement? resultElement = null;
if (!string.IsNullOrEmpty(result))
@@ -548,6 +623,35 @@ await response.WriteAsJsonAsync(
return response;
}
+ ///
+ /// Creates the response returned when a workflow continues asynchronously.
+ ///
+ internal static async Task CreateWorkflowAcceptedResponseAsync(
+ HttpRequestData req,
+ string workflowName,
+ string instanceId,
+ CancellationToken cancellationToken)
+ {
+ HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
+ if (ShouldReturnWorkflowJson(req))
+ {
+ await response.WriteAsJsonAsync(
+ new WorkflowAcceptedResponse(
+ instanceId,
+ $"Workflow orchestration started for {workflowName}."),
+ cancellationToken);
+ }
+ else
+ {
+ response.Headers.Add("Content-Type", "text/plain");
+ await response.WriteStringAsync(
+ $"Workflow orchestration started for {workflowName}. Orchestration runId: {instanceId}",
+ cancellationToken);
+ }
+
+ return response;
+ }
+
///
/// Creates an error response with the specified status code and error message.
///
@@ -644,13 +748,18 @@ private static async Task CreateAcceptedResponseAsync(
///
/// Returns when the caller has requested waiting for the workflow/agent to complete,
- /// as indicated by the x-ms-wait-for-response header. Falls back to
- /// when the header is absent or not a valid boolean.
+ /// as indicated by the x-ms-wait-for-response header or query parameter.
+ /// The header takes precedence. Falls back to when neither value is valid.
///
- private static bool ShouldWaitForResponse(HttpRequestData req, bool defaultValue)
+ internal static bool ShouldWaitForResponse(HttpRequestData req, string parameterName, bool defaultValue)
{
if (req.Headers.TryGetValues(WaitForResponseHeaderName, out IEnumerable? values) &&
- bool.TryParse(values.FirstOrDefault(), out bool parsed))
+ TryParseBoolean(values.FirstOrDefault(), out bool parsed))
+ {
+ return parsed;
+ }
+
+ if (TryParseBoolean(req.Query[parameterName], out parsed))
{
return parsed;
}
@@ -658,6 +767,114 @@ private static bool ShouldWaitForResponse(HttpRequestData req, bool defaultValue
return defaultValue;
}
+ ///
+ /// Gets the workflow wait preference while rejecting invalid values of the new query parameter.
+ /// The legacy header retains precedence and its existing invalid-value fallback behavior.
+ ///
+ internal static bool TryGetWorkflowWaitForResponse(
+ HttpRequestData req,
+ out bool waitForResponse,
+ out string? error)
+ {
+ if (req.Headers.TryGetValues(WaitForResponseHeaderName, out IEnumerable? values) &&
+ TryParseBoolean(values.FirstOrDefault(), out waitForResponse))
+ {
+ error = null;
+ return true;
+ }
+
+ string? queryValue = req.Query[WorkflowWaitForResponseParameterName];
+ if (queryValue is null)
+ {
+ waitForResponse = false;
+ error = null;
+ return true;
+ }
+
+ if (!TryParseBoolean(queryValue, out waitForResponse))
+ {
+ error = $"'{WorkflowWaitForResponseParameterName}' must be a boolean value.";
+ return false;
+ }
+
+ error = null;
+ return true;
+ }
+
+ private static bool TryParseBoolean(string? value, out bool result)
+ {
+ switch (value?.Trim().ToUpperInvariant())
+ {
+ case "TRUE":
+ case "1":
+ case "YES":
+ case "Y":
+ case "ON":
+ result = true;
+ return true;
+ case "FALSE":
+ case "0":
+ case "NO":
+ case "N":
+ case "OFF":
+ result = false;
+ return true;
+ default:
+ result = false;
+ return false;
+ }
+ }
+
+ ///
+ /// Gets the maximum time to wait for a synchronous workflow response.
+ ///
+ internal static bool TryGetWaitTimeout(HttpRequestData req, out TimeSpan timeout, out string? error)
+ {
+ string? value = req.Query[WaitTimeoutSecondsParameterName];
+ if (value is null)
+ {
+ timeout = TimeSpan.FromSeconds(DefaultWaitTimeoutSeconds);
+ error = null;
+ return true;
+ }
+
+ if (!int.TryParse(value, out int seconds) || seconds <= 0 || seconds > MaxWaitTimeoutSeconds)
+ {
+ timeout = default;
+ error = $"'{WaitTimeoutSecondsParameterName}' must be an integer between 1 and {MaxWaitTimeoutSeconds}.";
+ return false;
+ }
+
+ timeout = TimeSpan.FromSeconds(seconds);
+ error = null;
+ return true;
+ }
+
+ ///
+ /// Validates a caller-provided workflow run ID against the Durable Task instance ID contract.
+ ///
+ internal static bool TryValidateWorkflowRunId(string? runId, out string? error)
+ {
+ if (runId is null)
+ {
+ error = null;
+ return true;
+ }
+
+ if (runId.Length is < 1 or > MaxWorkflowRunIdLength ||
+ runId[0] == '@' ||
+ runId.IndexOfAny(['/', '\\', '#', '?']) >= 0 ||
+ runId.Any(char.IsControl))
+ {
+ error = $"'{WorkflowRunIdParameterName}' must be between 1 and {MaxWorkflowRunIdLength} characters, " +
+ "must not start with '@', and must not contain '/', '\\', '#', '?', or control characters.";
+ return false;
+ }
+
+ error = null;
+ return true;
+ }
+
///
/// Returns when the request accepts the application/json media type.
///
@@ -670,6 +887,36 @@ private static bool AcceptsJson(HttpRequestData req)
.Contains("application/json", StringComparer.OrdinalIgnoreCase);
}
+ ///
+ /// Returns only when the caller explicitly requests a text response
+ /// without also accepting JSON. Workflow endpoints otherwise default to JSON.
+ ///
+ internal static bool ShouldReturnWorkflowJson(HttpRequestData req)
+ {
+ if (!req.Headers.TryGetValues("Accept", out IEnumerable? acceptValues))
+ {
+ return true;
+ }
+
+ string[] mediaTypes = acceptValues
+ .SelectMany(v => v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ .Select(v => MediaTypeWithQualityHeaderValue.TryParse(v, out MediaTypeWithQualityHeaderValue? mediaType)
+ ? mediaType
+ : null)
+ .OfType()
+ .Where(v => (v.Quality ?? 1) > 0)
+ .Select(v => v.MediaType!)
+ .ToArray();
+
+ bool acceptsJson = mediaTypes.Contains("application/json", StringComparer.OrdinalIgnoreCase) ||
+ mediaTypes.Contains("application/*", StringComparer.OrdinalIgnoreCase) ||
+ mediaTypes.Contains("*/*", StringComparer.OrdinalIgnoreCase);
+ bool acceptsText = mediaTypes.Contains("text/plain", StringComparer.OrdinalIgnoreCase) ||
+ mediaTypes.Contains("text/*", StringComparer.OrdinalIgnoreCase);
+
+ return acceptsJson || !acceptsText;
+ }
+
///
/// Resolves the session key for an agent run request from the four places a caller may supply it:
/// the canonical session_id and its deprecated thread_id alias, in both the request body
@@ -851,6 +1098,15 @@ private sealed record WorkflowRunResponse(
[property: JsonPropertyName("result")] JsonElement? Result,
[property: JsonPropertyName("error"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Error = null);
+ ///
+ /// Represents a workflow run that continues asynchronously.
+ ///
+ /// The orchestration run ID.
+ /// A human-readable description of the accepted workflow run.
+ private sealed record WorkflowAcceptedResponse(
+ [property: JsonPropertyName("runId")] string RunId,
+ [property: JsonPropertyName("message")] string Message);
+
///
/// A service provider that combines the original service provider with an additional DurableTaskClient instance.
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md
index 95e7ce0..7f27f09 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] 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))
- [BREAKING] Replace "thread" with "session" in HTTP and MCP APIs ([#47](https://github.com/microsoft/agent-framework-durable-extension/pull/47))
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/BuiltInFunctionsWorkflowRoutingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/BuiltInFunctionsWorkflowRoutingTests.cs
index 5c671d3..8f28849 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/BuiltInFunctionsWorkflowRoutingTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/BuiltInFunctionsWorkflowRoutingTests.cs
@@ -1,5 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
+using System.Collections.Specialized;
+using System.Net;
+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;
public sealed class BuiltInFunctionsWorkflowRoutingTests
@@ -49,4 +59,429 @@ public void IsOrchestrationOwnedByWorkflow_ValidatesCorrectly(
// Assert
Assert.Equal(expectedResult, result);
}
+
+ [Theory]
+ [InlineData(null, null, false, false)]
+ [InlineData(null, "true", false, true)]
+ [InlineData("false", "true", true, false)]
+ [InlineData("invalid", "true", false, true)]
+ [InlineData("1", "false", false, true)]
+ [InlineData("0", "true", true, false)]
+ [InlineData(null, "yes", false, true)]
+ [InlineData(null, "off", true, false)]
+ public void ShouldWaitForResponse_UsesHeaderThenQueryThenDefault(
+ string? headerValue,
+ string? queryValue,
+ bool defaultValue,
+ bool expected)
+ {
+ HttpRequestData request = CreateRequest(headerValue, queryValue);
+
+ bool result = BuiltInFunctions.ShouldWaitForResponse(request, "waitForResponse", defaultValue);
+
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData("waitForResponse", "wait_for_response")] // workflow endpoints use camelCase
+ [InlineData("wait_for_response", "waitForResponse")] // agent endpoints use snake_case
+ public void ShouldWaitForResponse_OnlyHonorsTheParameterForItsSurface(string parameterName, string otherName)
+ {
+ HttpRequestData matching = CreateRequest(waitForResponse: "true", waitForResponseParameterName: parameterName);
+ HttpRequestData mismatched = CreateRequest(waitForResponse: "true", waitForResponseParameterName: otherName);
+
+ Assert.True(BuiltInFunctions.ShouldWaitForResponse(matching, parameterName, defaultValue: false));
+ Assert.False(BuiltInFunctions.ShouldWaitForResponse(mismatched, parameterName, defaultValue: false));
+ }
+
+ [Theory]
+ [InlineData(null, null, true, false)]
+ [InlineData("invalid", null, true, false)]
+ [InlineData("invalid", "true", true, true)]
+ [InlineData("false", "invalid", true, false)]
+ [InlineData(null, "invalid", false, false)]
+ public void TryGetWorkflowWaitForResponse_ValidatesQueryAfterHeader(
+ string? headerValue,
+ string? queryValue,
+ bool expectedSuccess,
+ bool expectedWait)
+ {
+ HttpRequestData request = CreateRequest(headerValue, queryValue);
+
+ bool success = BuiltInFunctions.TryGetWorkflowWaitForResponse(
+ request,
+ out bool waitForResponse,
+ out string? error);
+
+ Assert.Equal(expectedSuccess, success);
+ Assert.Equal(expectedWait, waitForResponse);
+ Assert.Equal(expectedSuccess, error is null);
+ }
+
+ [Theory]
+ [InlineData(null, true, 10)]
+ [InlineData("1", true, 1)]
+ [InlineData("200", true, 200)]
+ [InlineData("0", false, 0)]
+ [InlineData("201", false, 0)]
+ [InlineData("invalid", false, 0)]
+ [InlineData("", false, 0)]
+ [InlineData(" ", false, 0)]
+ public void TryGetWaitTimeout_ValidatesSeconds(string? value, bool expectedSuccess, int expectedSeconds)
+ {
+ HttpRequestData request = CreateRequest(timeoutSeconds: value);
+
+ bool success = BuiltInFunctions.TryGetWaitTimeout(request, out TimeSpan timeout, out string? error);
+
+ Assert.Equal(expectedSuccess, success);
+ Assert.Equal(expectedSeconds, timeout.TotalSeconds);
+ Assert.Equal(expectedSuccess, error is null);
+ }
+
+ [Theory]
+ [InlineData(null, true)]
+ [InlineData("workflow-123", true)]
+ [InlineData("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true)]
+ [InlineData("", false)]
+ [InlineData("@workflow", false)]
+ [InlineData("workflow/123", false)]
+ [InlineData("workflow\\123", false)]
+ [InlineData("workflow#123", false)]
+ [InlineData("workflow?123", false)]
+ [InlineData("workflow\n123", false)]
+ [InlineData("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", false)]
+ public void TryValidateWorkflowRunId_EnforcesDurableTaskContract(string? runId, bool expected)
+ {
+ bool success = BuiltInFunctions.TryValidateWorkflowRunId(runId, out string? error);
+
+ Assert.Equal(expected, success);
+ Assert.Equal(expected, error is null);
+ }
+
+ [Theory]
+ [InlineData(null, true)]
+ [InlineData("application/json", true)]
+ [InlineData("application/*", true)]
+ [InlineData("*/*", true)]
+ [InlineData("text/plain", false)]
+ [InlineData("text/*", false)]
+ [InlineData("text/plain, application/json", true)]
+ [InlineData("text/plain, application/json;q=0", false)]
+ [InlineData("text/plain;q=0", true)]
+ [InlineData("image/png", true)]
+ [InlineData("not-a-media-type", true)]
+ [InlineData("not-a-media-type, text/plain", false)]
+ public void ShouldReturnWorkflowJson_DefaultsToJsonUnlessOnlyTextIsAccepted(
+ string? accept,
+ bool expected)
+ {
+ (HttpRequestData request, _, _) = CreateCompletionRequest(CancellationToken.None, accept);
+
+ Assert.Equal(expected, BuiltInFunctions.ShouldReturnWorkflowJson(request));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("text/plain")]
+ public async Task WaitForWorkflowCompletionAsync_Timeout_ReturnsAsyncWorkflowResponseAsync(string? accept)
+ {
+ const string WorkflowName = "TestWorkflow";
+ const string InstanceId = "workflow-123";
+ (HttpRequestData asyncRequest, _, FunctionContext asyncContext) =
+ CreateCompletionRequest(CancellationToken.None, accept);
+ HttpResponseData expectedResponse = await BuiltInFunctions.CreateWorkflowAcceptedResponseAsync(
+ asyncRequest, WorkflowName, InstanceId, asyncContext.CancellationToken);
+ (HttpRequestData request, _, FunctionContext context) =
+ CreateCompletionRequest(CancellationToken.None, accept);
+ Mock client = CreateWaitingClient(InstanceId);
+
+ HttpResponseData response = await BuiltInFunctions.WaitForWorkflowCompletionAsync(
+ request,
+ client.Object,
+ context,
+ WorkflowName,
+ InstanceId,
+ TimeSpan.Zero);
+
+ Assert.Equal(expectedResponse.StatusCode, response.StatusCode);
+ Assert.Equal(GetResponseBody(expectedResponse), GetResponseBody(response));
+ Assert.Equal(
+ expectedResponse.Headers.Select(header => (header.Key, string.Join(",", header.Value))),
+ response.Headers.Select(header => (header.Key, string.Join(",", header.Value))));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("text/plain")]
+ public async Task CreateWorkflowAcceptedResponseAsync_HonorsContentNegotiationAsync(string? accept)
+ {
+ const string WorkflowName = "TestWorkflow";
+ const string InstanceId = "workflow-123";
+ (HttpRequestData request, _, FunctionContext context) =
+ CreateCompletionRequest(CancellationToken.None, accept);
+
+ HttpResponseData response = await BuiltInFunctions.CreateWorkflowAcceptedResponseAsync(
+ request, WorkflowName, InstanceId, context.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
+ if (accept is null)
+ {
+ AssertJsonContentType(response);
+ using JsonDocument body = JsonDocument.Parse(GetResponseBody(response));
+ Assert.Equal(InstanceId, body.RootElement.GetProperty("runId").GetString());
+ Assert.Equal(
+ $"Workflow orchestration started for {WorkflowName}.",
+ body.RootElement.GetProperty("message").GetString());
+ }
+ else
+ {
+ Assert.Equal(
+ $"Workflow orchestration started for {WorkflowName}. Orchestration runId: {InstanceId}",
+ GetResponseBody(response));
+ AssertTextContentType(response);
+ }
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("text/plain")]
+ public async Task WaitForWorkflowCompletionAsync_Completed_HonorsContentNegotiationAsync(string? accept)
+ {
+ const string InstanceId = "workflow-123";
+ const string Result = "Workflow completed.";
+ (HttpRequestData request, _, FunctionContext context) =
+ CreateCompletionRequest(CancellationToken.None, accept);
+ OrchestrationMetadata metadata = new("dafx-TestWorkflow", InstanceId)
+ {
+ RuntimeStatus = OrchestrationRuntimeStatus.Completed,
+ DataConverter = Microsoft.DurableTask.Converters.JsonDataConverter.Default,
+ SerializedOutput = JsonSerializer.Serialize(new { Result }),
+ };
+ Mock client = CreateCompletedClient(InstanceId, metadata);
+
+ HttpResponseData response = await BuiltInFunctions.WaitForWorkflowCompletionAsync(
+ request,
+ client.Object,
+ context,
+ "TestWorkflow",
+ InstanceId,
+ TimeSpan.FromSeconds(1));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ if (accept is null)
+ {
+ AssertJsonContentType(response);
+ using JsonDocument body = JsonDocument.Parse(GetResponseBody(response));
+ Assert.Equal(InstanceId, body.RootElement.GetProperty("runId").GetString());
+ Assert.Equal("Completed", body.RootElement.GetProperty("workflowStatus").GetString());
+ Assert.Equal(Result, body.RootElement.GetProperty("result").GetString());
+ }
+ else
+ {
+ Assert.Equal(Result, GetResponseBody(response));
+ AssertTextContentType(response);
+ }
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("text/plain")]
+ public async Task WaitForWorkflowCompletionAsync_Failed_HonorsContentNegotiationAsync(string? accept)
+ {
+ const string InstanceId = "workflow-123";
+ (HttpRequestData request, _, FunctionContext context) =
+ CreateCompletionRequest(CancellationToken.None, accept);
+ OrchestrationMetadata metadata = new("dafx-TestWorkflow", InstanceId)
+ {
+ RuntimeStatus = OrchestrationRuntimeStatus.Failed,
+ };
+ Mock client = CreateCompletedClient(InstanceId, metadata);
+
+ HttpResponseData response = await BuiltInFunctions.WaitForWorkflowCompletionAsync(
+ request,
+ client.Object,
+ context,
+ "TestWorkflow",
+ InstanceId,
+ TimeSpan.FromSeconds(1));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ if (accept is null)
+ {
+ AssertJsonContentType(response);
+ using JsonDocument body = JsonDocument.Parse(GetResponseBody(response));
+ Assert.Equal(InstanceId, body.RootElement.GetProperty("runId").GetString());
+ Assert.Equal("Failed", body.RootElement.GetProperty("workflowStatus").GetString());
+ Assert.Equal("Unknown error", body.RootElement.GetProperty("error").GetString());
+ }
+ else
+ {
+ Assert.Equal("Unknown error", GetResponseBody(response));
+ AssertTextContentType(response);
+ }
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("text/plain")]
+ public async Task RunWorkflowOrchestrationHttpTriggerAsync_ValidationError_HonorsContentNegotiationAsync(
+ string? accept)
+ {
+ (HttpRequestData request, _, FunctionContext context) =
+ CreateCompletionRequest(CancellationToken.None, accept);
+ Mock.Get(request).SetupGet(r => r.Body).Returns(new MemoryStream());
+ Mock functionDefinition = new();
+ functionDefinition.SetupGet(d => d.Name).Returns("http-TestWorkflow");
+ Mock.Get(context).SetupGet(c => c.FunctionDefinition).Returns(functionDefinition.Object);
+ Mock client = new("test");
+
+ HttpResponseData response = await BuiltInFunctions.RunWorkflowOrchestrationHttpTriggerAsync(
+ request, client.Object, context);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ if (accept is null)
+ {
+ AssertJsonContentType(response);
+ using JsonDocument body = JsonDocument.Parse(GetResponseBody(response));
+ Assert.Equal(400, body.RootElement.GetProperty("status").GetInt32());
+ Assert.Equal("Workflow input cannot be empty.", body.RootElement.GetProperty("error").GetString());
+ }
+ else
+ {
+ Assert.Equal("Workflow input cannot be empty.", GetResponseBody(response));
+ AssertTextContentType(response);
+ }
+ }
+
+ [Fact]
+ public async Task WaitForWorkflowCompletionAsync_CallerCancellation_PropagatesAsync()
+ {
+ const string WorkflowName = "TestWorkflow";
+ const string InstanceId = "workflow-123";
+ using CancellationTokenSource callerCancellation = new();
+ callerCancellation.Cancel();
+ HttpRequestData request = CreateRequest();
+ Mock context = new();
+ context.SetupGet(c => c.CancellationToken).Returns(callerCancellation.Token);
+ Mock client = CreateWaitingClient(InstanceId);
+
+ await Assert.ThrowsAnyAsync(() =>
+ BuiltInFunctions.WaitForWorkflowCompletionAsync(
+ request,
+ client.Object,
+ context.Object,
+ WorkflowName,
+ InstanceId,
+ TimeSpan.FromSeconds(30)));
+ }
+
+ private static HttpRequestData CreateRequest(
+ string? headerValue = null,
+ string? waitForResponse = null,
+ string? timeoutSeconds = null,
+ string waitForResponseParameterName = "waitForResponse")
+ {
+ HttpHeadersCollection headers = new();
+ if (headerValue is not null)
+ {
+ headers.Add("x-ms-wait-for-response", headerValue);
+ }
+
+ NameValueCollection query = new();
+ if (waitForResponse is not null)
+ {
+ query.Add(waitForResponseParameterName, waitForResponse);
+ }
+
+ if (timeoutSeconds is not null)
+ {
+ query.Add("timeoutSeconds", timeoutSeconds);
+ }
+
+ Mock request = new(MockBehavior.Strict, Mock.Of());
+ request.SetupGet(r => r.Headers).Returns(headers);
+ request.SetupGet(r => r.Query).Returns(query);
+ return request.Object;
+ }
+
+ private static Mock CreateWaitingClient(string instanceId)
+ {
+ Mock client = new("test");
+ client.Setup(c => c.WaitForInstanceCompletionAsync(instanceId, true, It.IsAny()))
+ .Returns(async (string _, bool _, CancellationToken cancellation) =>
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellation);
+ throw new InvalidOperationException("The infinite wait completed without cancellation.");
+ });
+ return client;
+ }
+
+ private static Mock CreateCompletedClient(
+ string instanceId,
+ OrchestrationMetadata metadata)
+ {
+ Mock client = new("test");
+ client.Setup(c => c.WaitForInstanceCompletionAsync(instanceId, true, It.IsAny()))
+ .ReturnsAsync(metadata);
+ return client;
+ }
+
+ private static (HttpRequestData Request, HttpResponseData Response, FunctionContext Context) CreateCompletionRequest(
+ CancellationToken cancellationToken,
+ string? accept = null)
+ {
+ Mock serializer = new();
+ serializer
+ .Setup(s => s.SerializeAsync(
+ It.IsAny(),
+ It.IsAny