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..4ae8e12 --- /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, 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, + 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 e74d7d0..f92f124 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 c6f86a3..cbed5f9 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, 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)) - [BREAKING] Removed the `AddAIAgents` and `AddWorkflows` bulk registration APIs and changed `AddWorkflow` to return `DurableWorkflowOptions` so multiple workflows can be registered fluently ([#39](https://github.com/microsoft/agent-framework-durable-extension/pull/39)) - Use "session" instead of "thread" terminology in documentation and API comments ([#47](https://github.com/microsoft/agent-framework-durable-extension/pull/47)) - 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)) 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..88d1fa3 --- /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)} or {nameof(ServiceCollectionExtensions.ConfigureDurableOptions)} 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..5df51e0 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,41 @@ 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. + /// Thrown when is 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/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/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 23c4a1b..95e7ce0 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] 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)) - [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)) 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/DurableStreamingWorkflowRunTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs index 0aa3166..30946ff 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; } 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..8baff71 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowClientTests.cs @@ -0,0 +1,228 @@ +// 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(); + foreach (Workflow workflow in workflows) + { + options.Workflows.AddWorkflow(workflow); + } + + 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..4282734 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/DurableTaskClientWorkflowExtensionsTests.cs @@ -0,0 +1,145 @@ +// 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(); + using ServiceProvider provider = CreateServiceProviderWithWorkflows(); + FunctionContext context = CreateContext(provider); + + // 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(); + using ServiceProvider provider = CreateServiceProviderWithWorkflows(); + FunctionContext context = CreateContext(provider); + + // 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(); + using ServiceProvider provider = new ServiceCollection().BuildServiceProvider(); + FunctionContext context = CreateContext(provider); + + // 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(); + using ServiceProvider provider = CreateServiceProviderWithWorkflows(); + FunctionContext context = CreateContext(provider); + + 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; + } +}