From 9108d316073da35e80a5cce468bb3aeae68f8bba Mon Sep 17 00:00:00 2001 From: Chris Gillum Date: Fri, 24 Jul 2026 16:37:53 -0700 Subject: [PATCH 1/7] Add a Functions-native API for invoking Durable Workflows Starting a durable workflow from inside an Azure Function required building an HttpClient and POSTing to the workflow's generated HTTP route, which coupled function code to that route. IWorkflowClient already existed, but it was only reachable from the console and worker host, and every overload required the Workflow object, which function classes generally don't have a reference to. Add name-based RunAsync and StreamAsync overloads that resolve against the registered workflows, and expose the client to function code through DurableTaskClient.AsWorkflowClient(FunctionContext), mirroring the existing AsDurableAgentProxy pattern. Starting an unregistered name fails fast with the new WorkflowNotRegisteredException. Fixes #24 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../01_SequentialWorkflow/OrderFunctions.cs | 89 +++++++ .../01_SequentialWorkflow/Program.cs | 2 + .../01_SequentialWorkflow/README.md | 33 +++ .../01_SequentialWorkflow/demo.http | 6 + .../CHANGELOG.md | 1 + .../WorkflowNotRegisteredException.cs | 47 ++++ .../Workflows/DurableWorkflowClient.cs | 61 ++++- .../Workflows/IWorkflowClient.cs | 64 +++++ .../CHANGELOG.md | 1 + .../DurableTaskClientExtensions.cs | 49 ++++ .../Workflows/DurableWorkflowClientTests.cs | 224 ++++++++++++++++++ .../WorkflowSamplesValidation.cs | 16 ++ ...urableTaskClientWorkflowExtensionsTests.cs | 141 +++++++++++ 13 files changed, 732 insertions(+), 2 deletions(-) create mode 100644 dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/OrderFunctions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/WorkflowNotRegisteredException.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowClientTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableTaskClientWorkflowExtensionsTests.cs diff --git a/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/OrderFunctions.cs b/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/OrderFunctions.cs new file mode 100644 index 0000000..415cd1d --- /dev/null +++ b/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/OrderFunctions.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Hosting.AzureFunctions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask.Client; + +namespace SequentialWorkflow; + +/// +/// Demonstrates invoking a registered workflow from your own function code. +/// +/// +/// +/// These functions are hand-written endpoints that sit in front of the CancelOrder workflow, +/// rather than the endpoints the framework generates. Instead of creating an +/// and POSTing to workflows/CancelOrder/run, they get an from the +/// [DurableClient] binding and start the workflow by name. +/// +/// +/// The workflow is started through the durable backend rather than through the workflow's generated +/// HTTP route, so the call path does not depend on that route and the same two lines work from any +/// trigger type - queue, timer, Event Grid, Service Bus. +/// +/// +public sealed class OrderFunctions +{ + private const string CancelOrderWorkflow = "CancelOrder"; + + /// + /// Starts the CancelOrder workflow and returns its run ID immediately. + /// + /// The HTTP request. + /// The order to cancel, taken from the route. + /// The Durable Task client provided by the [DurableClient] binding. + /// The function invocation context. + /// Cancellation token. + /// A 202 Accepted response carrying the workflow run ID. + [Function(nameof(CancelOrderAsync))] + public async Task CancelOrderAsync( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "orders/{orderId}/cancel")] HttpRequest request, + string orderId, + [DurableClient] DurableTaskClient durableClient, + FunctionContext context, + CancellationToken cancellationToken) + { + // Get a workflow client from the durable client binding. No endpoint URI, no HttpClient. + IWorkflowClient workflows = durableClient.AsWorkflowClient(context); + + // Start the registered workflow by name. The Workflow object built in Program.cs is not needed here. + IWorkflowRun run = await workflows.RunAsync(CancelOrderWorkflow, orderId, cancellationToken: cancellationToken); + + return new AcceptedResult(location: null, value: $"Workflow orchestration started for {CancelOrderWorkflow}. Orchestration runId: {run.RunId}"); + } + + /// + /// Starts the CancelOrder workflow and waits for it to finish before responding. + /// + /// The HTTP request. + /// The order to cancel, taken from the route. + /// The Durable Task client provided by the [DurableClient] binding. + /// The function invocation context. + /// Cancellation token. + /// A 200 OK response carrying the workflow result. + [Function(nameof(CancelOrderAndWaitAsync))] + public async Task CancelOrderAndWaitAsync( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "orders/{orderId}/cancel-and-wait")] HttpRequest request, + string orderId, + [DurableClient] DurableTaskClient durableClient, + FunctionContext context, + CancellationToken cancellationToken) + { + IWorkflowClient workflows = durableClient.AsWorkflowClient(context); + + IWorkflowRun run = await workflows.RunAsync(CancelOrderWorkflow, orderId, cancellationToken: cancellationToken); + + // Durable workflow runs also implement IAwaitableWorkflowRun, so the same handle can be + // awaited for the final result. + if (run is not IAwaitableWorkflowRun awaitableRun) + { + return new AcceptedResult(location: null, value: run.RunId); + } + + string? result = await awaitableRun.WaitForCompletionAsync(cancellationToken); + return new OkObjectResult(result); + } +} diff --git a/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/Program.cs b/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/Program.cs index 20da58d..3d3818a 100644 --- a/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/Program.cs +++ b/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/Program.cs @@ -5,6 +5,8 @@ // The OrderStatus workflow looks up an order and generates a status report. // The BatchCancelOrders workflow accepts a complex JSON input to cancel multiple orders. // Both CancelOrder and OrderStatus reuse the same OrderLookup executor, demonstrating executor sharing. +// OrderFunctions.cs additionally shows how to start a registered workflow from your own function +// code using DurableTaskClient.AsWorkflowClient, without creating an HttpClient. using Microsoft.Agents.AI.Hosting.AzureFunctions; using Microsoft.Agents.AI.Workflows; diff --git a/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md b/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md index 4f455b3..4ef1c6a 100644 --- a/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md +++ b/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md @@ -9,6 +9,7 @@ This sample demonstrates how to use the Microsoft Agent Framework to create an A - Registering workflows with the Function app using `ConfigureDurableWorkflows` - Durable orchestration ensuring workflows survive process restarts and failures - Starting workflows via HTTP requests +- Invoking a workflow from your own function code with `DurableTaskClient.AsWorkflowClient`, without an `HttpClient` - Viewing workflow execution history and status in the Durable Task Scheduler (DTS) dashboard ## Workflows @@ -140,6 +141,38 @@ The `OrderStatus` workflow reuses the same `OrderLookup` executor and then gener │ [Activity] StatusReport: ✓ Order 12345 for Jerry: Status=Active, Date=2025-01-01 ``` +### Invoking a Workflow from Function Code + +The endpoints above are generated by the framework. `OrderFunctions.cs` shows the other direction: hand-written functions that start the same `CancelOrder` workflow from your own code, without creating an `HttpClient` or knowing the workflow's HTTP route. This alternative approach is useful when you want to start a workflow from a queue trigger, timer trigger, etc. or if you need more control over the HTTP request/response than the generated endpoints provide. + +```csharp +// Get a workflow client from the [DurableClient] binding, then start the workflow by name. +IWorkflowClient workflows = durableClient.AsWorkflowClient(context); +IWorkflowRun run = await workflows.RunAsync("CancelOrder", orderId); +``` + +The workflow is started through the durable backend rather than through the workflow's generated HTTP route, so the call path does not depend on that route and the same two lines work from any trigger type — queue, timer, Event Grid, Service Bus. If the workflow name is not registered, `RunAsync` throws `WorkflowNotRegisteredException` immediately instead of scheduling an orchestration that no worker can execute. + +Start the workflow and get back a run ID: + +```bash +curl -X POST http://localhost:7071/api/orders/12345/cancel +``` + +```text +Workflow orchestration started for CancelOrder. Orchestration runId: abc123def456 +``` + +Durable workflow runs also implement `IAwaitableWorkflowRun`, so the same handle can be awaited for the final result: + +```bash +curl -X POST http://localhost:7071/api/orders/12345/cancel-and-wait +``` + +```text +Cancellation email sent for order 12345 to jerry@example.com. +``` + ### Viewing Workflows in the DTS Dashboard After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the completed orchestration, inspect inputs/outputs for each step, and view execution history. diff --git a/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http b/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http index fb9793f..adbafad 100644 --- a/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http +++ b/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http @@ -46,3 +46,9 @@ POST {{authority}}/api/workflows/BatchCancelOrders/run Content-Type: application/json {"orderIds": ["1001", "1002", "1003"], "reason": "Customer requested cancellation", "notifyCustomers": true} + +### Cancel an order from function code (see OrderFunctions.cs), returning the run ID +POST {{authority}}/api/orders/12345/cancel + +### Cancel an order from function code and wait for the result +POST {{authority}}/api/orders/12345/cancel-and-wait diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md index 1dd55f2..0a46d0e 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- [BREAKING] Added `IWorkflowClient` overloads that start a registered workflow by name; external implementations of `IWorkflowClient` must implement the new members, and an untyped `null` first argument is now ambiguous between the `Workflow` and workflow-name overloads ([#48](https://github.com/microsoft/agent-framework-durable-extension/pull/48)) - Wrap RequestPort external responses in a controlled `DurableExecutorOutput` envelope so that only the `result` property is populated during deserialization ([#20](https://github.com/microsoft/agent-framework-durable-extension/pull/20)) - Bound the live workflow status to a trailing event window so multi-executor workflows with large typed outputs no longer overflow the Durable Task 16 KB custom status cap ([#6775](https://github.com/microsoft/agent-framework/pull/6775)) - Fixed `WorkflowOutputEvent` streaming deserialization to read `executorId` instead of the renamed `sourceId` property, with fallback for backward compatibility ([#6896](https://github.com/microsoft/agent-framework/pull/6896)) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/WorkflowNotRegisteredException.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/WorkflowNotRegisteredException.cs new file mode 100644 index 0000000..04967be --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/WorkflowNotRegisteredException.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Exception thrown when a workflow with the specified name has not been registered. +/// +public sealed class WorkflowNotRegisteredException : InvalidOperationException +{ + // Not used, but required by static analysis. + private WorkflowNotRegisteredException() + { + this.WorkflowName = string.Empty; + } + + /// + /// Initializes a new instance of the class with the workflow name. + /// + /// The name of the workflow that was not registered. + public WorkflowNotRegisteredException(string workflowName) + : base(GetMessage(workflowName)) + { + this.WorkflowName = workflowName; + } + + /// + /// Initializes a new instance of the class with the workflow name and an inner exception. + /// + /// The name of the workflow that was not registered. + /// The exception that is the cause of the current exception. + public WorkflowNotRegisteredException(string workflowName, Exception? innerException) + : base(GetMessage(workflowName), innerException) + { + this.WorkflowName = workflowName; + } + + /// + /// Gets the name of the workflow that was not registered. + /// + public string WorkflowName { get; } + + private static string GetMessage(string workflowName) + { + ArgumentException.ThrowIfNullOrEmpty(workflowName); + return $"No workflow named '{workflowName}' was registered. Ensure the workflow is registered using {nameof(ServiceCollectionExtensions.ConfigureDurableWorkflows)} before invoking it by name."; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowClient.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowClient.cs index 5944d57..c60a0f3 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowClient.cs @@ -13,16 +13,20 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows; internal sealed class DurableWorkflowClient : IWorkflowClient { private readonly DurableTaskClient _client; + private readonly DurableOptions _options; /// /// Initializes a new instance of the class. /// /// The durable task client for orchestration operations. - /// Thrown when is null. - public DurableWorkflowClient(DurableTaskClient client) + /// The durable options containing the registered workflows. + /// Thrown when or is null. + public DurableWorkflowClient(DurableTaskClient client, DurableOptions options) { ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(options); this._client = client; + this._options = options; } /// @@ -59,6 +63,23 @@ public ValueTask RunAsync( CancellationToken cancellationToken = default) => this.RunAsync(workflow, input, runId, cancellationToken); + /// + public ValueTask RunAsync( + string workflowName, + TInput input, + string? runId = null, + CancellationToken cancellationToken = default) + where TInput : notnull + => this.RunAsync(this.ResolveWorkflow(workflowName), input, runId, cancellationToken); + + /// + public ValueTask RunAsync( + string workflowName, + string input, + string? runId = null, + CancellationToken cancellationToken = default) + => this.RunAsync(workflowName, input, runId, cancellationToken); + /// public async ValueTask StreamAsync( Workflow workflow, @@ -92,4 +113,40 @@ public ValueTask StreamAsync( string? runId = null, CancellationToken cancellationToken = default) => this.StreamAsync(workflow, input, runId, cancellationToken); + + /// + public ValueTask StreamAsync( + string workflowName, + TInput input, + string? runId = null, + CancellationToken cancellationToken = default) + where TInput : notnull + => this.StreamAsync(this.ResolveWorkflow(workflowName), input, runId, cancellationToken); + + /// + public ValueTask StreamAsync( + string workflowName, + string input, + string? runId = null, + CancellationToken cancellationToken = default) + => this.StreamAsync(workflowName, input, runId, cancellationToken); + + /// + /// Resolves a registered workflow by name. + /// + /// The name of the workflow to resolve. + /// The registered . + /// Thrown when is null or empty. + /// Thrown when no workflow with the specified name has been registered. + private Workflow ResolveWorkflow(string workflowName) + { + ArgumentException.ThrowIfNullOrEmpty(workflowName); + + if (!this._options.Workflows.Workflows.TryGetValue(workflowName, out Workflow? workflow)) + { + throw new WorkflowNotRegisteredException(workflowName); + } + + return workflow; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowClient.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowClient.cs index e84f3fe..4043fca 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/IWorkflowClient.cs @@ -39,6 +39,38 @@ ValueTask RunAsync( string? runId = null, CancellationToken cancellationToken = default); + /// + /// Runs a registered workflow by name and returns a handle to monitor its execution. + /// + /// The type of the input to the workflow. + /// The name of a workflow that was registered via . + /// The input to pass to the workflow's starting executor. + /// Optional identifier for the run. If not provided, a new ID will be generated. + /// A cancellation token to observe. + /// An that can be used to monitor the workflow execution. + /// Thrown when no workflow with the specified name has been registered. + ValueTask RunAsync( + string workflowName, + TInput input, + string? runId = null, + CancellationToken cancellationToken = default) + where TInput : notnull; + + /// + /// Runs a registered workflow by name with string input and returns a handle to monitor its execution. + /// + /// The name of a workflow that was registered via . + /// The string input to pass to the workflow. + /// Optional identifier for the run. If not provided, a new ID will be generated. + /// A cancellation token to observe. + /// An that can be used to monitor the workflow execution. + /// Thrown when no workflow with the specified name has been registered. + ValueTask RunAsync( + string workflowName, + string input, + string? runId = null, + CancellationToken cancellationToken = default); + /// /// Starts a workflow and returns a streaming handle to watch events in real-time. /// @@ -68,4 +100,36 @@ ValueTask StreamAsync( string input, string? runId = null, CancellationToken cancellationToken = default); + + /// + /// Starts a registered workflow by name and returns a streaming handle to watch events in real-time. + /// + /// The type of the input to the workflow. + /// The name of a workflow that was registered via . + /// The input to pass to the workflow's starting executor. + /// Optional identifier for the run. If not provided, a new ID will be generated. + /// A cancellation token to observe. + /// An that can be used to stream workflow events. + /// Thrown when no workflow with the specified name has been registered. + ValueTask StreamAsync( + string workflowName, + TInput input, + string? runId = null, + CancellationToken cancellationToken = default) + where TInput : notnull; + + /// + /// Starts a registered workflow by name with string input and returns a streaming handle to watch events in real-time. + /// + /// The name of a workflow that was registered via . + /// The string input to pass to the workflow. + /// Optional identifier for the run. If not provided, a new ID will be generated. + /// A cancellation token to observe. + /// An that can be used to stream workflow events. + /// Thrown when no workflow with the specified name has been registered. + ValueTask StreamAsync( + string workflowName, + string input, + string? runId = null, + CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md index f2aa741..08531da 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] +- 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] Renamed `AddWorkflow` parameters `exposeStatusEndpoint` and `exposeMcpToolTrigger` to `enableStatusEndpoint` and `enableMcpToolTrigger` for consistency with `AddAIAgent` ([#35](https://github.com/microsoft/agent-framework-durable-extension/pull/35)) - Scope workflow status/respond endpoints to the route workflow name ([#6608](https://github.com/microsoft/agent-framework/pull/6608)) - Bind MCP threadId to the current agent and guard cross-agent session dispatch ([#6531](https://github.com/microsoft/agent-framework/pull/6531)) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs index 0977d75..6f696f8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.DurableTask; +using Microsoft.Agents.AI.DurableTask.Workflows; using Microsoft.Azure.Functions.Worker; using Microsoft.DurableTask.Client; using Microsoft.Extensions.DependencyInjection; @@ -45,4 +46,52 @@ public static AIAgent AsDurableAgentProxy( return new DurableAIAgentProxy(agentName, agentClient); } + + /// + /// Gets an for starting and monitoring durable workflows that were + /// registered with the function app. + /// + /// + /// This allows any function to invoke a registered workflow directly, without constructing an + /// endpoint URI or issuing an HTTP request. The workflow is started through the durable backend + /// rather than through the workflow's generated HTTP route, so the call path does not depend on + /// that route and works from any trigger type. + /// + /// The obtained from a [DurableClient] binding. + /// The for the current function invocation. + /// A workflow client scoped to the current function invocation. + /// Thrown when or is null. + /// + /// Thrown when durable services have not been configured on the application builder. Note that a + /// client is returned even when no workflows are registered; starting an unregistered workflow by + /// name then throws . + /// + /// + /// + /// [Function(nameof(CancelOrder))] + /// public async Task<IActionResult> CancelOrder( + /// [QueueTrigger("order-cancellations")] string orderId, + /// [DurableClient] DurableTaskClient durableClient, + /// FunctionContext context) + /// { + /// IWorkflowClient workflows = durableClient.AsWorkflowClient(context); + /// IWorkflowRun run = await workflows.RunAsync("CancelOrder", orderId); + /// return new OkObjectResult(run.RunId); + /// } + /// + /// + public static IWorkflowClient AsWorkflowClient( + this DurableTaskClient durableClient, + FunctionContext context) + { + ArgumentNullException.ThrowIfNull(durableClient); + ArgumentNullException.ThrowIfNull(context); + + DurableOptions options = context.InstanceServices.GetService() + ?? throw new InvalidOperationException( + $"Durable services have not been configured. Ensure {nameof(FunctionsApplicationBuilderExtensions.ConfigureDurableWorkflows)} " + + $"or {nameof(FunctionsApplicationBuilderExtensions.ConfigureDurableOptions)} has been called on the application builder."); + + return new DurableWorkflowClient(durableClient, options); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowClientTests.cs new file mode 100644 index 0000000..bf3e0df --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowClientTests.cs @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Moq; + +namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; + +/// +/// Tests for the name-based overloads of , which allow a workflow to be +/// invoked without a reference to the object. +/// +public sealed class DurableWorkflowClientTests +{ + private const string WorkflowTestName = "TestWorkflow"; + private const string OrchestrationName = "dafx-" + WorkflowTestName; + private const string InstanceId = "test-instance-123"; + + [Fact] + public async Task RunAsync_ByName_SchedulesOrchestrationForRegisteredWorkflowAsync() + { + // Arrange + Mock mockClient = CreateMockClient(); + DurableWorkflowClient client = CreateClient(mockClient, CreateTestWorkflow()); + + // Act + IWorkflowRun run = await client.RunAsync(WorkflowTestName, "hello"); + + // Assert + Assert.Equal(InstanceId, run.RunId); + mockClient.Verify( + c => c.ScheduleNewOrchestrationInstanceAsync( + It.Is(n => n.Name == OrchestrationName), + It.Is(o => ((DurableWorkflowInput)o).Input == "hello"), + null, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task RunAsync_ByName_UsesRunIdAsInstanceIdAsync() + { + // Arrange + Mock mockClient = CreateMockClient(); + DurableWorkflowClient client = CreateClient(mockClient, CreateTestWorkflow()); + + // Act + await client.RunAsync(WorkflowTestName, "hello", runId: "custom-run-id"); + + // Assert + mockClient.Verify( + c => c.ScheduleNewOrchestrationInstanceAsync( + It.IsAny(), + It.IsAny(), + It.Is(o => o.InstanceId == "custom-run-id"), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task RunAsync_ByName_SupportsTypedInputAsync() + { + // Arrange + Mock mockClient = CreateMockClient(); + DurableWorkflowClient client = CreateClient(mockClient, CreateTestWorkflow()); + OrderRequest input = new("order-123"); + + // Act + await client.RunAsync(WorkflowTestName, input); + + // Assert + mockClient.Verify( + c => c.ScheduleNewOrchestrationInstanceAsync( + It.Is(n => n.Name == OrchestrationName), + It.Is(o => ((DurableWorkflowInput)o).Input == input), + null, + It.IsAny()), + Times.Once); + } + + // The registry is case-insensitive, and lookups resolve to the canonically-registered workflow, + // so the orchestration name uses the registered casing rather than the caller's. + [Fact] + public async Task RunAsync_ByName_IsCaseInsensitiveAsync() + { + // Arrange + Mock mockClient = CreateMockClient(); + DurableWorkflowClient client = CreateClient(mockClient, CreateTestWorkflow()); + + // Act + await client.RunAsync("testWORKFLOW", "hello"); + + // Assert + mockClient.Verify( + c => c.ScheduleNewOrchestrationInstanceAsync( + It.Is(n => n.Name == OrchestrationName), + It.IsAny(), + null, + It.IsAny()), + Times.Once); + } + + // A typo'd or unregistered name should fail fast with an actionable error rather than + // scheduling an orchestration that no worker can execute. + [Fact] + public async Task RunAsync_ByName_ThrowsWhenWorkflowNotRegisteredAsync() + { + // Arrange + Mock mockClient = CreateMockClient(); + DurableWorkflowClient client = CreateClient(mockClient, CreateTestWorkflow()); + + // Act + WorkflowNotRegisteredException ex = await Assert.ThrowsAsync( + async () => await client.RunAsync("UnknownWorkflow", "hello")); + + // Assert + Assert.Equal("UnknownWorkflow", ex.WorkflowName); + Assert.Contains("UnknownWorkflow", ex.Message, StringComparison.Ordinal); + mockClient.Verify( + c => c.ScheduleNewOrchestrationInstanceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RunAsync_ByName_ThrowsWhenNameIsEmptyAsync() + { + // Arrange + Mock mockClient = CreateMockClient(); + DurableWorkflowClient client = CreateClient(mockClient, CreateTestWorkflow()); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await client.RunAsync(string.Empty, "hello")); + } + + // A bare `null` literal is ambiguous between the Workflow and workflow-name overloads, + // so the cast pins this to the name-based overload. + [Fact] + public async Task RunAsync_ByName_ThrowsWhenNameIsNullAsync() + { + // Arrange + Mock mockClient = CreateMockClient(); + DurableWorkflowClient client = CreateClient(mockClient, CreateTestWorkflow()); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await client.RunAsync((string)null!, "hello")); + } + + [Fact] + public async Task StreamAsync_ByName_SchedulesOrchestrationForRegisteredWorkflowAsync() + { + // Arrange + Mock mockClient = CreateMockClient(); + DurableWorkflowClient client = CreateClient(mockClient, CreateTestWorkflow()); + + // Act + IStreamingWorkflowRun run = await client.StreamAsync(WorkflowTestName, "hello"); + + // Assert + Assert.Equal(InstanceId, run.RunId); + mockClient.Verify( + c => c.ScheduleNewOrchestrationInstanceAsync( + It.Is(n => n.Name == OrchestrationName), + It.Is(o => ((DurableWorkflowInput)o).Input == "hello"), + null, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task StreamAsync_ByName_ThrowsWhenWorkflowNotRegisteredAsync() + { + // Arrange + Mock mockClient = CreateMockClient(); + DurableWorkflowClient client = CreateClient(mockClient, CreateTestWorkflow()); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await client.StreamAsync("UnknownWorkflow", "hello")); + } + + [Fact] + public void Constructor_ThrowsWhenArgumentsAreNull() + { + Mock mockClient = CreateMockClient(); + + Assert.Throws(() => new DurableWorkflowClient(null!, new DurableOptions())); + Assert.Throws(() => new DurableWorkflowClient(mockClient.Object, null!)); + } + + private static Mock CreateMockClient() + { + Mock mockClient = new("test"); + mockClient + .Setup(c => c.ScheduleNewOrchestrationInstanceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(InstanceId); + return mockClient; + } + + private static DurableWorkflowClient CreateClient(Mock mockClient, params Workflow[] workflows) + { + DurableOptions options = new(); + options.Workflows.AddWorkflows(workflows); + return new DurableWorkflowClient(mockClient.Object, options); + } + + private static Workflow CreateTestWorkflow() => + new WorkflowBuilder(new FunctionExecutor("start", (_, _, _) => default)) + .WithName(WorkflowTestName) + .Build(); + + private sealed record OrderRequest(string OrderId); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs index 5489b85..fbc0318 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs @@ -165,6 +165,22 @@ await this.WaitForConditionAsync( Assert.Equal("Completed", statusEl.GetString()); Assert.True(root.TryGetProperty("result", out JsonElement resultEl), "JSON response missing 'result' property"); Assert.Contains("77777", resultEl.GetString()); + + // Test starting the workflow from function code via DurableTaskClient.AsWorkflowClient + // (see OrderFunctions.cs). This route does not go through the generated workflow HTTP + // endpoint; the function starts the orchestration directly through the durable backend. + Uri nativeCancelUri = new($"http://localhost:{AzureFunctionsPort}/api/orders/88888/cancel-and-wait"); + this._outputHelper.WriteLine($"Starting CancelOrder workflow from function code via POST request to {nativeCancelUri}..."); + + using CancellationTokenSource nativeCts = new(s_orchestrationTimeout); + using HttpResponseMessage nativeResponse = await s_sharedHttpClient.PostAsync(nativeCancelUri, content: null, nativeCts.Token); + + Assert.True(nativeResponse.IsSuccessStatusCode, $"Functions-native CancelOrder request failed with status: {nativeResponse.StatusCode}"); + string nativeResponseText = await nativeResponse.Content.ReadAsStringAsync(); + this._outputHelper.WriteLine($"Functions-native CancelOrder result: {nativeResponseText}"); + + // The response is the workflow result, proving the workflow ran to completion. + Assert.Contains("88888", nativeResponseText); }); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableTaskClientWorkflowExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableTaskClientWorkflowExtensionsTests.cs new file mode 100644 index 0000000..a2ccc95 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableTaskClientWorkflowExtensionsTests.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask; +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests; + +/// +/// Tests for , which gives function code a +/// Functions-native way to invoke a registered workflow without going through its HTTP surface. +/// +public sealed class DurableTaskClientWorkflowExtensionsTests +{ + private const string WorkflowTestName = "TestWorkflow"; + private const string OrchestrationName = "dafx-" + WorkflowTestName; + private const string InstanceId = "test-instance-123"; + + // The end-to-end assertion for this feature: a function holding only a [DurableClient] binding and + // a FunctionContext can start a registered workflow by name. + [Fact] + public async Task AsWorkflowClient_StartsRegisteredWorkflowByNameAsync() + { + // Arrange + Mock mockClient = CreateMockClient(); + FunctionContext context = CreateContext(CreateServiceProviderWithWorkflows()); + + // Act + IWorkflowClient workflowClient = mockClient.Object.AsWorkflowClient(context); + IWorkflowRun run = await workflowClient.RunAsync(WorkflowTestName, "hello"); + + // Assert + Assert.Equal(InstanceId, run.RunId); + mockClient.Verify( + c => c.ScheduleNewOrchestrationInstanceAsync( + It.Is(n => n.Name == OrchestrationName), + It.IsAny(), + null, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task AsWorkflowClient_ThrowsWhenWorkflowNotRegisteredAsync() + { + // Arrange + Mock mockClient = CreateMockClient(); + FunctionContext context = CreateContext(CreateServiceProviderWithWorkflows()); + + // Act + IWorkflowClient workflowClient = mockClient.Object.AsWorkflowClient(context); + + // Assert + await Assert.ThrowsAsync( + async () => await workflowClient.RunAsync("UnknownWorkflow", "hello")); + } + + // Without any durable configuration there is nothing to resolve names against, so surface an + // actionable configuration error rather than a dependency-resolution failure. + [Fact] + public void AsWorkflowClient_ThrowsWhenDurableServicesNotConfigured() + { + // Arrange + Mock mockClient = CreateMockClient(); + FunctionContext context = CreateContext(new ServiceCollection().BuildServiceProvider()); + + // Act & Assert + InvalidOperationException ex = Assert.Throws( + () => mockClient.Object.AsWorkflowClient(context)); + + Assert.Contains(nameof(FunctionsApplicationBuilderExtensions.ConfigureDurableWorkflows), ex.Message, StringComparison.Ordinal); + } + + // ConfigureDurableAgents also registers DurableOptions, so an agent-only app gets a usable client. + // The failure surfaces at call time, as the more precise WorkflowNotRegisteredException. + [Fact] + public async Task AsWorkflowClient_InAgentOnlyAppThrowsWorkflowNotRegisteredAsync() + { + // Arrange + Mock mockClient = CreateMockClient(); + ServiceCollection services = new(); + services.ConfigureDurableAgents(agents => agents.AddAIAgent(new TestAgent("TestAgent", "An agent used for testing."))); + using ServiceProvider provider = services.BuildServiceProvider(); + FunctionContext context = CreateContext(provider); + + // Act + IWorkflowClient workflowClient = mockClient.Object.AsWorkflowClient(context); + + // Assert + WorkflowNotRegisteredException ex = await Assert.ThrowsAsync( + async () => await workflowClient.RunAsync(WorkflowTestName, "hello")); + + Assert.Equal(WorkflowTestName, ex.WorkflowName); + } + + [Fact] + public void AsWorkflowClient_ThrowsOnNullArguments() + { + Mock mockClient = CreateMockClient(); + FunctionContext context = CreateContext(CreateServiceProviderWithWorkflows()); + + Assert.Throws(() => DurableTaskClientExtensions.AsWorkflowClient(null!, context)); + Assert.Throws(() => mockClient.Object.AsWorkflowClient(null!)); + } + + private static Mock CreateMockClient() + { + Mock mockClient = new("test"); + mockClient + .Setup(c => c.ScheduleNewOrchestrationInstanceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(InstanceId); + return mockClient; + } + + private static ServiceProvider CreateServiceProviderWithWorkflows() + { + Workflow workflow = new WorkflowBuilder(new FunctionExecutor("start", (_, _, _) => default)) + .WithName(WorkflowTestName) + .Build(); + + ServiceCollection services = new(); + services.ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(workflow)); + return services.BuildServiceProvider(); + } + + private static FunctionContext CreateContext(IServiceProvider services) + { + Mock mockContext = new(); + mockContext.SetupGet(c => c.InstanceServices).Returns(services); + return mockContext.Object; + } +} From 93bc8a88c710dba11b1bdb44c9ee1e70053a8084 Mon Sep 17 00:00:00 2001 From: Chris Gillum Date: Fri, 24 Jul 2026 16:52:47 -0700 Subject: [PATCH 2/7] Dispose ServiceProvider instances in AsWorkflowClient tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../DurableTaskClientWorkflowExtensionsTests.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableTaskClientWorkflowExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableTaskClientWorkflowExtensionsTests.cs index a2ccc95..4282734 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableTaskClientWorkflowExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableTaskClientWorkflowExtensionsTests.cs @@ -28,7 +28,8 @@ public async Task AsWorkflowClient_StartsRegisteredWorkflowByNameAsync() { // Arrange Mock mockClient = CreateMockClient(); - FunctionContext context = CreateContext(CreateServiceProviderWithWorkflows()); + using ServiceProvider provider = CreateServiceProviderWithWorkflows(); + FunctionContext context = CreateContext(provider); // Act IWorkflowClient workflowClient = mockClient.Object.AsWorkflowClient(context); @@ -50,7 +51,8 @@ public async Task AsWorkflowClient_ThrowsWhenWorkflowNotRegisteredAsync() { // Arrange Mock mockClient = CreateMockClient(); - FunctionContext context = CreateContext(CreateServiceProviderWithWorkflows()); + using ServiceProvider provider = CreateServiceProviderWithWorkflows(); + FunctionContext context = CreateContext(provider); // Act IWorkflowClient workflowClient = mockClient.Object.AsWorkflowClient(context); @@ -67,7 +69,8 @@ public void AsWorkflowClient_ThrowsWhenDurableServicesNotConfigured() { // Arrange Mock mockClient = CreateMockClient(); - FunctionContext context = CreateContext(new ServiceCollection().BuildServiceProvider()); + using ServiceProvider provider = new ServiceCollection().BuildServiceProvider(); + FunctionContext context = CreateContext(provider); // Act & Assert InvalidOperationException ex = Assert.Throws( @@ -102,7 +105,8 @@ public async Task AsWorkflowClient_InAgentOnlyAppThrowsWorkflowNotRegisteredAsyn public void AsWorkflowClient_ThrowsOnNullArguments() { Mock mockClient = CreateMockClient(); - FunctionContext context = CreateContext(CreateServiceProviderWithWorkflows()); + using ServiceProvider provider = CreateServiceProviderWithWorkflows(); + FunctionContext context = CreateContext(provider); Assert.Throws(() => DurableTaskClientExtensions.AsWorkflowClient(null!, context)); Assert.Throws(() => mockClient.Object.AsWorkflowClient(null!)); From 41ba08625a8b40321bb6a7c4201cb5f5d4da0b86 Mon Sep 17 00:00:00 2001 From: Chris Gillum Date: Fri, 24 Jul 2026 17:06:49 -0700 Subject: [PATCH 3/7] Correct ResolveWorkflow exception documentation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Workflows/DurableWorkflowClient.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowClient.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowClient.cs index c60a0f3..5df51e0 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowClient.cs @@ -136,7 +136,8 @@ public ValueTask StreamAsync( /// /// The name of the workflow to resolve. /// The registered . - /// Thrown when is null or empty. + /// Thrown when is null. + /// Thrown when is empty. /// Thrown when no workflow with the specified name has been registered. private Workflow ResolveWorkflow(string workflowName) { From ab681b88beabcad2db5eb74bb3c2d2b76e2ec9af Mon Sep 17 00:00:00 2001 From: Chris Gillum Date: Fri, 24 Jul 2026 17:19:13 -0700 Subject: [PATCH 4/7] Address review: broaden not-registered guidance and correct returns doc Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AzureFunctions/01_SequentialWorkflow/OrderFunctions.cs | 2 +- .../WorkflowNotRegisteredException.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/OrderFunctions.cs b/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/OrderFunctions.cs index 415cd1d..4ae8e12 100644 --- a/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/OrderFunctions.cs +++ b/dotnet/samples/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/OrderFunctions.cs @@ -63,7 +63,7 @@ public async Task CancelOrderAsync( /// The Durable Task client provided by the [DurableClient] binding. /// The function invocation context. /// Cancellation token. - /// A 200 OK response carrying the workflow result. + /// A 200 OK response carrying the workflow result, or 202 Accepted carrying the run ID if the run handle does not support awaiting completion. [Function(nameof(CancelOrderAndWaitAsync))] public async Task CancelOrderAndWaitAsync( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "orders/{orderId}/cancel-and-wait")] HttpRequest request, diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/WorkflowNotRegisteredException.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/WorkflowNotRegisteredException.cs index 04967be..88d1fa3 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/WorkflowNotRegisteredException.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/WorkflowNotRegisteredException.cs @@ -42,6 +42,6 @@ public WorkflowNotRegisteredException(string workflowName, Exception? innerExcep private static string GetMessage(string workflowName) { ArgumentException.ThrowIfNullOrEmpty(workflowName); - return $"No workflow named '{workflowName}' was registered. Ensure the workflow is registered using {nameof(ServiceCollectionExtensions.ConfigureDurableWorkflows)} before invoking it by name."; + return $"No workflow named '{workflowName}' was registered. Ensure the workflow is registered using {nameof(ServiceCollectionExtensions.ConfigureDurableWorkflows)} or {nameof(ServiceCollectionExtensions.ConfigureDurableOptions)} before invoking it by name."; } } From 5af8c15b7079e4772d3c7e65601ace28f220b2c1 Mon Sep 17 00:00:00 2001 From: Chris Gillum Date: Fri, 24 Jul 2026 17:45:57 -0700 Subject: [PATCH 5/7] Fix workflow result deserialization under the Azure Functions host The Azure Functions worker serializes orchestration output with the Durable Task default JsonDataConverter, which applies no naming policy and writes PascalCase. DurableWorkflowJsonContext read it back with a camelCase, case-sensitive source-generated context, so every property silently bound to null and the workflow result was lost. The built-in HTTP endpoint was unaffected because it round-trips through the same converter via ReadOutputAs(). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CHANGELOG.md | 2 +- .../Workflows/DurableWorkflowJsonContext.cs | 9 ++++- .../DurableStreamingWorkflowRunTests.cs | 38 +++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md index 0a46d0e..efc1dbd 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] -- [BREAKING] Added `IWorkflowClient` overloads that start a registered workflow by name; external implementations of `IWorkflowClient` must implement the new members, and an untyped `null` first argument is now ambiguous between the `Workflow` and workflow-name overloads ([#48](https://github.com/microsoft/agent-framework-durable-extension/pull/48)) +- [BREAKING] Added `IWorkflowClient` overloads that start a registered workflow by name, and made workflow result deserialization case-insensitive so results can be read back when hosted in Azure Functions. External implementations of `IWorkflowClient` must implement the new members, and an untyped `null` first argument is now ambiguous between the `Workflow` and workflow-name overloads ([#48](https://github.com/microsoft/agent-framework-durable-extension/pull/48)) - Wrap RequestPort external responses in a controlled `DurableExecutorOutput` envelope so that only the `result` property is populated during deserialization ([#20](https://github.com/microsoft/agent-framework-durable-extension/pull/20)) - Bound the live workflow status to a trailing event window so multi-executor workflows with large typed outputs no longer overflow the Durable Task 16 KB custom status cap ([#6775](https://github.com/microsoft/agent-framework/pull/6775)) - Fixed `WorkflowOutputEvent` streaming deserialization to read `executorId` instead of the renamed `sourceId` property, with fallback for backward compatibility ([#6896](https://github.com/microsoft/agent-framework/pull/6896)) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowJsonContext.cs index 12f4c49..49b0de8 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowJsonContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowJsonContext.cs @@ -22,11 +22,18 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows; /// Note: User-defined executor input/output types still use reflection-based serialization /// since their types are not known at compile time. /// +/// +/// Deserialization is case-insensitive because these payloads are not always written by this +/// library. When hosted in Azure Functions, the Durable Task worker serializes orchestration +/// output with the default JsonDataConverter, which applies no naming policy and therefore +/// writes PascalCase. Matching case-sensitively would silently bind every property to null. +/// /// [JsonSourceGenerationOptions( WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true)] [JsonSerializable(typeof(DurableActivityInput))] [JsonSerializable(typeof(DurableExecutorOutput))] [JsonSerializable(typeof(TypedPayload))] diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs index 0aa3166..7273335 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs @@ -1006,6 +1006,44 @@ public async Task WatchStreamAsync_EmptyWindowForOversizedEvent_DeliversAtComple #endregion + #region Host serialization compatibility + + // The Azure Functions worker serializes orchestration output with the Durable Task default + // JsonDataConverter, which applies no naming policy and therefore writes PascalCase. Reading it + // back must not depend on the camelCase names this library writes, otherwise every property + // silently binds to null and the workflow result is lost. + [Theory] + [InlineData("""{"Result":"\"hello\"","Events":[],"SentMessages":[],"HaltRequested":false}""")] + [InlineData("""{"result":"\"hello\"","events":[],"sentMessages":[],"haltRequested":false}""")] + public void ExtractResult_ReadsResultRegardlessOfPropertyCasing(string serializedOutput) + { + // Act + string? result = DurableStreamingWorkflowRun.ExtractResult(serializedOutput); + + // Assert + Assert.Equal("\"hello\"", result); + } + + // Same concern for the events collection, which the streaming path backfills from the output. + [Fact] + public void ExtractResult_ReadsPascalCaseTypedResult() + { + // Arrange + string payloadJson = JsonSerializer.Serialize(new TestPayload { Name = "n", Value = 7 }, DurableSerialization.Options); + string serializedOutput = JsonSerializer.Serialize( + new Dictionary { ["Result"] = payloadJson, ["Events"] = Array.Empty() }); + + // Act + TestPayload? result = DurableStreamingWorkflowRun.ExtractResult(serializedOutput); + + // Assert + Assert.NotNull(result); + Assert.Equal("n", result.Name); + Assert.Equal(7, result.Value); + } + + #endregion + private sealed class TestPayload { public string? Name { get; set; } From 8dc21eb95724a525989de85659eb9f311fd8d3b1 Mon Sep 17 00:00:00 2001 From: Chris Gillum Date: Mon, 27 Jul 2026 10:06:28 -0700 Subject: [PATCH 6/7] Correct host serialization test fixture Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Workflows/DurableStreamingWorkflowRunTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs index 7273335..30946ff 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs @@ -1013,15 +1013,15 @@ public async Task WatchStreamAsync_EmptyWindowForOversizedEvent_DeliversAtComple // back must not depend on the camelCase names this library writes, otherwise every property // silently binds to null and the workflow result is lost. [Theory] - [InlineData("""{"Result":"\"hello\"","Events":[],"SentMessages":[],"HaltRequested":false}""")] - [InlineData("""{"result":"\"hello\"","events":[],"sentMessages":[],"haltRequested":false}""")] + [InlineData("""{"Result":"hello","Events":[],"SentMessages":[],"HaltRequested":false}""")] + [InlineData("""{"result":"hello","events":[],"sentMessages":[],"haltRequested":false}""")] public void ExtractResult_ReadsResultRegardlessOfPropertyCasing(string serializedOutput) { // Act string? result = DurableStreamingWorkflowRun.ExtractResult(serializedOutput); // Assert - Assert.Equal("\"hello\"", result); + Assert.Equal("hello", result); } // Same concern for the events collection, which the streaming path backfills from the output. From e4c1725649edb19aa0518f142a1ab8b8c416f330 Mon Sep 17 00:00:00 2001 From: Chris Gillum Date: Mon, 27 Jul 2026 15:57:21 -0700 Subject: [PATCH 7/7] Adapt workflow tests to bulk API removal Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Workflows/DurableWorkflowClientTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowClientTests.cs index bf3e0df..8baff71 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowClientTests.cs @@ -211,7 +211,11 @@ private static Mock CreateMockClient() private static DurableWorkflowClient CreateClient(Mock mockClient, params Workflow[] workflows) { DurableOptions options = new(); - options.Workflows.AddWorkflows(workflows); + foreach (Workflow workflow in workflows) + { + options.Workflows.AddWorkflow(workflow); + } + return new DurableWorkflowClient(mockClient.Object, options); }