diff --git a/docs/features/durable-agents/README.md b/docs/features/durable-agents/README.md
index d18418b..3a801be 100644
--- a/docs/features/durable-agents/README.md
+++ b/docs/features/durable-agents/README.md
@@ -93,6 +93,20 @@ using IHost app = FunctionsApplication
app.Run();
```
+To host durable workflows alongside agents, add a `ConfigureDurableWorkflows` call. The two methods compose, in any order and any number of times:
+
+```csharp
+using IHost app = FunctionsApplication
+ .CreateBuilder(args)
+ .ConfigureFunctionsWebApplication()
+ .ConfigureDurableAgents(agents => agents.AddAIAgent(agent))
+ .ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(workflow))
+ .Build();
+app.Run();
+```
+
+Alternatively, `ConfigureDurableOptions` configures both from a single delegate and can be freely mixed with the methods above.
+
**Python example:**
```python
diff --git a/dotnet/samples/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs b/dotnet/samples/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs
index fcb9906..06653b9 100644
--- a/dotnet/samples/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs
+++ b/dotnet/samples/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/Program.cs
@@ -1,8 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
-// This sample demonstrates using ConfigureDurableOptions to register BOTH agents AND workflows
-// in a single Azure Functions app. It uses a workflow to translate text and a standalone AI agent
-// accessible via HTTP and MCP tool triggers.
+// This sample demonstrates registering BOTH agents AND workflows in a single Azure Functions app.
+// It uses a workflow to translate text and a standalone AI agent accessible via HTTP and MCP tool
+// triggers.
+//
+// ConfigureDurableAgents and ConfigureDurableWorkflows compose: call them in any order, as many
+// times as you like, and the configurations are additive. ConfigureDurableOptions is an equivalent
+// alternative that configures both from a single delegate - see the README for that variant.
#pragma warning disable IDE0002 // Simplify Member Access
@@ -49,17 +53,15 @@
.AddEdge(translateText, formatOutput)
.Build();
-// Use ConfigureDurableOptions to register both agents and workflows together
+// Register agents and workflows through separate, composable calls.
using IHost app = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
- .ConfigureDurableOptions(options =>
- {
- // Register the standalone agent with HTTP and MCP tool triggers
- options.Agents.AddAIAgent(assistant, enableHttpTrigger: true, enableMcpToolTrigger: true);
- // Register the workflow with an HTTP endpoint and MCP tool trigger
- options.Workflows.AddWorkflow(translateWorkflow, enableStatusEndpoint: false, enableMcpToolTrigger: true);
- })
+ // Register the standalone agent with HTTP and MCP tool triggers
+ .ConfigureDurableAgents(agents => agents.AddAIAgent(assistant, enableHttpTrigger: true, enableMcpToolTrigger: true))
+
+ // Register the workflow with an HTTP endpoint and MCP tool trigger
+ .ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(translateWorkflow, enableStatusEndpoint: false, enableMcpToolTrigger: true))
.Build();
app.Run();
diff --git a/dotnet/samples/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md b/dotnet/samples/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md
index 34d6843..12ffbb9 100644
--- a/dotnet/samples/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md
+++ b/dotnet/samples/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/README.md
@@ -1,10 +1,32 @@
# Workflow and Agents Sample
-This sample demonstrates how to use `ConfigureDurableOptions` to register **both** AI agents **and** workflows in a single Azure Functions app. This is the recommended approach when your application needs both standalone agents and orchestrated workflows.
+This sample demonstrates how to register **both** AI agents **and** workflows in a single Azure Functions app, using separate `ConfigureDurableAgents` and `ConfigureDurableWorkflows` calls.
+
+These methods compose: call them in any order, as many times as you like, and the configurations are additive.
+
+```csharp
+using IHost app = FunctionsApplication
+ .CreateBuilder(args)
+ .ConfigureFunctionsWebApplication()
+ .ConfigureDurableAgents(agents => agents.AddAIAgent(assistant, enableHttpTrigger: true, enableMcpToolTrigger: true))
+ .ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(translateWorkflow, enableMcpToolTrigger: true))
+ .Build();
+app.Run();
+```
+
+If you prefer to configure everything from a single delegate, `ConfigureDurableOptions` is an equivalent alternative and can be freely mixed with the two methods above:
+
+```csharp
+ .ConfigureDurableOptions(options =>
+ {
+ options.Agents.AddAIAgent(assistant, enableHttpTrigger: true, enableMcpToolTrigger: true);
+ options.Workflows.AddWorkflow(translateWorkflow, enableMcpToolTrigger: true);
+ })
+```
## Key Concepts Demonstrated
-- **Unified Configuration**: Use `ConfigureDurableOptions` to register agents and workflows together
+- **Composable Configuration**: `ConfigureDurableAgents` and `ConfigureDurableWorkflows` combine in the same app
- **Standalone Agent**: An AI agent accessible via HTTP and MCP tool triggers
- **Workflow**: A simple text translation workflow also exposed as an MCP tool
- **Mixed Triggers**: Both agents and workflows coexist in the same Functions host
diff --git a/dotnet/samples/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/Program.cs b/dotnet/samples/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/Program.cs
index b94cbd2..df53f89 100644
--- a/dotnet/samples/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/Program.cs
+++ b/dotnet/samples/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/Program.cs
@@ -2,11 +2,12 @@
// This sample demonstrates the THREE ways to configure durable agents and workflows:
//
-// 1. ConfigureDurableAgents() - For standalone agents only
-// 2. ConfigureDurableWorkflows() - For workflows only
-// 3. ConfigureDurableOptions() - For both agents AND workflows
+// 1. ConfigureDurableAgents() - Configures agents
+// 2. ConfigureDurableWorkflows() - Configures workflows
+// 3. ConfigureDurableOptions() - Configures agents and workflows from a single delegate
//
-// KEY: All methods can be called MULTIPLE times - configurations are ADDITIVE.
+// KEY: All three methods compose. Call them in any order, as many times as you like - the
+// configurations are ADDITIVE and share a single underlying options instance.
using Azure.AI.OpenAI;
using Azure.Identity;
@@ -67,16 +68,16 @@
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
- // METHOD 1: ConfigureDurableAgents - for standalone agents only
+ // METHOD 1: ConfigureDurableAgents - configures agents
services.ConfigureDurableAgents(
options => options.AddAIAgent(biologist),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
- // METHOD 2: ConfigureDurableWorkflows - for workflows only
+ // METHOD 2: ConfigureDurableWorkflows - configures workflows
services.ConfigureDurableWorkflows(options => options.AddWorkflow(physicsWorkflow));
- // METHOD 3: ConfigureDurableOptions - for both agents AND workflows
+ // METHOD 3: ConfigureDurableOptions - configures both from a single delegate
services.ConfigureDurableOptions(options =>
{
options.Agents.AddAIAgent(chemist);
diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md
index 463b792..2ed7852 100644
--- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md
+++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md
@@ -2,6 +2,7 @@
## [Unreleased]
+- Fixed `ConfigureDurableAgents` and `ConfigureDurableWorkflows` silently dropping the `workerBuilder` or `clientBuilder` supplied to a later call, so the Durable Task worker and client are now registered regardless of which configuration call provides them ([#67](https://github.com/microsoft/agent-framework-durable-extension/pull/67))
- Fixed a `JsonTypeInfo metadata ... was not provided` failure when persisting agent state for function calls or results that carry values the state serializer has no metadata for, such as the `AIContent` results returned by MCP tools ([#57](https://github.com/microsoft/agent-framework-durable-extension/pull/57))
- [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))
diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableServicesMarker.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableServicesMarker.cs
index 58dea9b..a00ac84 100644
--- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableServicesMarker.cs
+++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableServicesMarker.cs
@@ -32,3 +32,26 @@ namespace Microsoft.Agents.AI.DurableTask;
///
///
internal sealed class DurableServicesMarker;
+
+///
+/// Marker class used to track whether the Durable Task worker has been configured.
+///
+///
+/// The worker is tracked separately from because a worker builder
+/// may be supplied by any Configure* call, not just the first one:
+///
+/// services.ConfigureDurableAgents(...); // 1st call - no worker builder
+/// services.ConfigureDurableWorkflows(..., workerBuilder: b => ...); // 2nd call - worker builder
+///
+/// A single shared marker would cause the second call's builder to be silently dropped.
+///
+internal sealed class DurableTaskWorkerMarker;
+
+///
+/// Marker class used to track whether the Durable Task client has been configured.
+///
+///
+/// See for why the client is tracked separately from
+/// .
+///
+internal sealed class DurableTaskClientMarker;
diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs
index 456e4ae..046580e 100644
--- a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs
@@ -184,6 +184,13 @@ private static void EnsureDurableServicesRegistered(
DurableOptions sharedOptions,
Action? workerBuilder,
Action? clientBuilder)
+ {
+ EnsureSharedServicesRegistered(services);
+ EnsureWorkerRegistered(services, sharedOptions, workerBuilder);
+ EnsureClientRegistered(services, clientBuilder);
+ }
+
+ private static void EnsureSharedServicesRegistered(IServiceCollection services)
{
// Use a marker to ensure we only register core services once
if (services.Any(d => d.ServiceType == typeof(DurableServicesMarker)))
@@ -195,27 +202,6 @@ private static void EnsureDurableServicesRegistered(
services.TryAddSingleton();
- // Configure Durable Task Worker - capture sharedOptions reference in closure.
- // The options object is populated by all Configure* calls before the worker starts.
-
- if (workerBuilder is not null)
- {
- services.AddDurableTaskWorker(builder =>
- {
- workerBuilder?.Invoke(builder);
-
- builder.AddTasks(registry => RegisterTasksFromOptions(registry, sharedOptions));
- });
- }
-
- // Configure Durable Task Client
- if (clientBuilder is not null)
- {
- services.AddDurableTaskClient(clientBuilder);
- services.TryAddSingleton();
- services.TryAddSingleton();
- }
-
// Register workflow and agent services
services.TryAddSingleton();
@@ -227,6 +213,54 @@ private static void EnsureDurableServicesRegistered(
services.TryAddSingleton(sp => sp.GetRequiredService().Agents);
}
+ ///
+ /// Configures the Durable Task worker the first time a Configure* call supplies a worker
+ /// builder. The builder is tracked separately from the other core services so that a builder
+ /// supplied to a later call is still honored.
+ ///
+ private static void EnsureWorkerRegistered(
+ IServiceCollection services,
+ DurableOptions sharedOptions,
+ Action? workerBuilder)
+ {
+ if (workerBuilder is null || services.Any(d => d.ServiceType == typeof(DurableTaskWorkerMarker)))
+ {
+ return;
+ }
+
+ services.AddSingleton();
+
+ // Capture the sharedOptions reference in the closure.
+ // The options object is populated by all Configure* calls before the worker starts.
+ services.AddDurableTaskWorker(builder =>
+ {
+ workerBuilder(builder);
+
+ builder.AddTasks(registry => RegisterTasksFromOptions(registry, sharedOptions));
+ });
+ }
+
+ ///
+ /// Configures the Durable Task client the first time a Configure* call supplies a client
+ /// builder. The builder is tracked separately from the other core services so that a builder
+ /// supplied to a later call is still honored.
+ ///
+ private static void EnsureClientRegistered(
+ IServiceCollection services,
+ Action? clientBuilder)
+ {
+ if (clientBuilder is null || services.Any(d => d.ServiceType == typeof(DurableTaskClientMarker)))
+ {
+ return;
+ }
+
+ services.AddSingleton();
+
+ services.AddDurableTaskClient(clientBuilder);
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ }
+
private static void RegisterTasksFromOptions(DurableTaskRegistry registry, DurableOptions durableOptions)
{
// Build registrations for all workflows including sub-workflows
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md
index 7f27f09..7a3dbd6 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]
+- Fixed `ConfigureDurableAgents` and `ConfigureDurableWorkflows` not composing when both are called on the same application: calling `ConfigureDurableAgents` first left the workflow functions without an executor, and calling `ConfigureDurableWorkflows` first registered the built-in function execution middleware twice ([#67](https://github.com/microsoft/agent-framework-durable-extension/pull/67))
- [BREAKING] Support bounded synchronous workflow HTTP invocation through query parameters and default workflow run responses to JSON, with `Accept: text/plain` available for the legacy text format and the same negotiated asynchronous response returned on timeout ([#52](https://github.com/microsoft/agent-framework-durable-extension/pull/52))
- Added `DurableTaskClient.AsWorkflowClient` so functions can invoke durable workflows without constructing an `HttpClient` ([#48](https://github.com/microsoft/agent-framework-durable-extension/pull/48))
- [BREAKING] Consolidated the `AddWorkflow` extension overloads into a single method with optional `enableStatusEndpoint` and `enableMcpToolTrigger` parameters, and changed it to return `DurableWorkflowOptions` instead of `void` so multiple workflows can be registered fluently ([#39](https://github.com/microsoft/agent-framework-durable-extension/pull/39))
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs
index 3c5e793..b94688d 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/FunctionsApplicationBuilderExtensions.cs
@@ -18,6 +18,10 @@ public static class FunctionsApplicationBuilderExtensions
///
/// Configures the application to use durable agents with a builder pattern.
///
+ ///
+ /// Multiple calls to this method, and calls combined with or
+ /// , are supported and compose additively.
+ ///
/// The functions application builder.
/// A delegate to configure the durable agents.
/// The functions application builder.
@@ -25,31 +29,10 @@ public static FunctionsApplicationBuilder ConfigureDurableAgents(
this FunctionsApplicationBuilder builder,
Action configure)
{
+ ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(configure);
- // Create/get shared options BEFORE the DurableTask library call so it can find them.
- FunctionsDurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services);
-
- // The main agent services registration is done in Microsoft.DurableTask.Agents.
- builder.Services.ConfigureDurableAgents(configure);
-
- // Ensure all agents registered through this path have default FunctionsAgentOptions.
- // This distinguishes them from agents auto-registered by workflows.
- DurableAgentsOptionsExtensions.EnsureDefaultOptionsForAll(sharedOptions.Agents.GetAgentFactories().Keys);
-
- builder.Services.TryAddSingleton(_ =>
- new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot()));
-
- builder.Services.AddSingleton();
-
- // Handling of built-in function execution for Agent HTTP, MCP tool, or Entity invocations.
- builder.UseWhen(static context =>
- string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal) ||
- string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, StringComparison.Ordinal) ||
- string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal));
- builder.Services.AddSingleton();
-
- return builder;
+ return ConfigureDurableCore(builder, options => configure(options.Agents), applyDefaultAgentOptions: true);
}
///
@@ -69,11 +52,63 @@ public static FunctionsApplicationBuilder ConfigureDurableOptions(
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(configure);
+ return ConfigureDurableCore(builder, configure, applyDefaultAgentOptions: false);
+ }
+
+ ///
+ /// Configures durable workflow support for the specified Azure Functions application builder.
+ ///
+ ///
+ /// Multiple calls to this method, and calls combined with or
+ /// , are supported and compose additively.
+ ///
+ /// The instance to configure for durable workflows.
+ /// An action that configures the , allowing customization of durable workflow behavior.
+ /// The updated instance, enabling method chaining.
+ public static FunctionsApplicationBuilder ConfigureDurableWorkflows(
+ this FunctionsApplicationBuilder builder,
+ Action configure)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(configure);
+
+ return ConfigureDurableCore(builder, options => configure(options.Workflows), applyDefaultAgentOptions: false);
+ }
+
+ ///
+ /// Applies a configuration delegate to the shared instance and registers the
+ /// Functions-specific services. All public configuration entry points funnel through here so that agents and
+ /// workflows are wired identically no matter which combination of methods the application calls, or in which
+ /// order.
+ ///
+ /// The functions application builder.
+ /// A delegate to apply to the shared durable options.
+ ///
+ /// When , agents added by that have no explicit
+ /// receive the defaults for the agent-focused entry point. Agents already
+ /// present, such as those auto-registered by a previously configured workflow, are left untouched.
+ ///
+ private static FunctionsApplicationBuilder ConfigureDurableCore(
+ FunctionsApplicationBuilder builder,
+ Action configure,
+ bool applyDefaultAgentOptions)
+ {
// Ensure FunctionsDurableOptions is registered BEFORE the core extension creates a plain DurableOptions
FunctionsDurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services);
+ // Agent names are case-insensitive everywhere else, so this snapshot must match that comparer.
+ HashSet agentsBeforeConfigure = applyDefaultAgentOptions
+ ? new HashSet(sharedOptions.Agents.GetAgentFactories().Keys, StringComparer.OrdinalIgnoreCase)
+ : new HashSet(StringComparer.OrdinalIgnoreCase);
+
builder.Services.ConfigureDurableOptions(configure);
+ if (applyDefaultAgentOptions)
+ {
+ DurableAgentsOptionsExtensions.EnsureDefaultOptionsForAll(
+ sharedOptions.Agents.GetAgentFactories().Keys.Where(name => !agentsBeforeConfigure.Contains(name)));
+ }
+
if (DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot().Count > 0)
{
builder.Services.TryAddSingleton(_ =>
@@ -91,21 +126,6 @@ public static FunctionsApplicationBuilder ConfigureDurableOptions(
return builder;
}
- ///
- /// Configures durable workflow support for the specified Azure Functions application builder.
- ///
- /// The instance to configure for durable workflows.
- /// An action that configures the , allowing customization of durable workflow behavior.
- /// The updated instance, enabling method chaining.
- public static FunctionsApplicationBuilder ConfigureDurableWorkflows(
- this FunctionsApplicationBuilder builder,
- Action configure)
- {
- ArgumentNullException.ThrowIfNull(configure);
-
- return builder.ConfigureDurableOptions(options => configure(options.Workflows));
- }
-
private static void EnsureMiddlewareRegistered(FunctionsApplicationBuilder builder)
{
// Guard against registering the middleware filter multiple times in the pipeline.
diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableConfigurationCompositionTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableConfigurationCompositionTests.cs
new file mode 100644
index 0000000..3da9240
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableConfigurationCompositionTests.cs
@@ -0,0 +1,134 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+using Microsoft.Agents.AI.DurableTask.Workflows;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.Agents.AI.DurableTask.UnitTests;
+
+///
+/// Tests that and
+/// compose when both are called
+/// on the same application, instead of forcing users onto
+/// .
+///
+///
+/// Regression coverage for https://github.com/microsoft/agent-framework-durable-extension/issues/27.
+///
+public sealed class DurableConfigurationCompositionTests
+{
+ [Fact]
+ public void ConfigureDurableAgentsThenWorkflows_RegistersBothAgentAndWorkflow()
+ {
+ ServiceCollection services = new();
+
+ services.ConfigureDurableAgents(agents => agents.AddAIAgent(new CompositionTestAgent("Assistant")));
+ services.ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(BuildWorkflow("Translate")));
+
+ DurableOptions options = GetRegisteredOptions(services);
+
+ Assert.Contains("Assistant", options.Agents.GetAgentFactories().Keys);
+ Assert.Contains("Translate", options.Workflows.Workflows.Keys);
+ }
+
+ [Fact]
+ public void ConfigureDurableWorkflowsThenAgents_RegistersBothAgentAndWorkflow()
+ {
+ ServiceCollection services = new();
+
+ services.ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(BuildWorkflow("Translate")));
+ services.ConfigureDurableAgents(agents => agents.AddAIAgent(new CompositionTestAgent("Assistant")));
+
+ DurableOptions options = GetRegisteredOptions(services);
+
+ Assert.Contains("Assistant", options.Agents.GetAgentFactories().Keys);
+ Assert.Contains("Translate", options.Workflows.Workflows.Keys);
+ }
+
+ ///
+ /// The first Configure* call latches the core service registrations, so worker and client
+ /// builders supplied to a later call must still be honored.
+ ///
+ [Fact]
+ public void ConfigureDurableAgentsThenWorkflows_HonorsWorkerAndClientBuildersFromSecondCall()
+ {
+ ServiceCollection services = new();
+
+ services.ConfigureDurableAgents(agents => agents.AddAIAgent(new CompositionTestAgent("Assistant")));
+ services.ConfigureDurableWorkflows(
+ workflows => workflows.AddWorkflow(BuildWorkflow("Translate")),
+ workerBuilder: _ => { },
+ clientBuilder: _ => { });
+
+ Assert.Contains(services, d => d.ServiceType == typeof(IWorkflowClient));
+ }
+
+ ///
+ /// Builders supplied to the first call must survive additional Configure* calls, and the
+ /// core services must not be registered twice.
+ ///
+ [Fact]
+ public void ConfigureDurableAgentsThenWorkflows_RegistersCoreServicesExactlyOnce()
+ {
+ ServiceCollection services = new();
+
+ services.ConfigureDurableAgents(
+ agents => agents.AddAIAgent(new CompositionTestAgent("Assistant")),
+ workerBuilder: _ => { },
+ clientBuilder: _ => { });
+ services.ConfigureDurableWorkflows(
+ workflows => workflows.AddWorkflow(BuildWorkflow("Translate")),
+ workerBuilder: _ => { },
+ clientBuilder: _ => { });
+
+ Assert.Equal(1, services.Count(d => d.ServiceType == typeof(IWorkflowClient)));
+ Assert.Equal(1, services.Count(d => d.ServiceType == typeof(DurableOptions)));
+ }
+
+ private static DurableOptions GetRegisteredOptions(IServiceCollection services)
+ {
+ ServiceDescriptor descriptor = Assert.Single(
+ services, d => d.ServiceType == typeof(DurableOptions));
+
+ return Assert.IsType(descriptor.ImplementationInstance, exactMatch: false);
+ }
+
+ private static Workflow BuildWorkflow(string name) =>
+ new WorkflowBuilder(new FunctionExecutor("start", (_, _, _) => default))
+ .WithName(name)
+ .Build();
+
+ private sealed class CompositionTestAgent(string name) : AIAgent
+ {
+ public override string? Name => name;
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default)
+ => new(new EmptySession());
+
+ protected override ValueTask SerializeSessionCoreAsync(
+ AgentSession session,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ CancellationToken cancellationToken = default) => throw new NotSupportedException();
+
+ protected override ValueTask DeserializeSessionCoreAsync(
+ JsonElement serializedState,
+ JsonSerializerOptions? jsonSerializerOptions = null,
+ CancellationToken cancellationToken = default) => new(new EmptySession());
+
+ protected override Task RunCoreAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default) => Task.FromResult(new AgentResponse([.. messages]));
+
+ protected override IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default) => throw new NotSupportedException();
+
+ private sealed class EmptySession : AgentSession;
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionsDurableConfigurationCompositionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionsDurableConfigurationCompositionTests.cs
new file mode 100644
index 0000000..8321711
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/FunctionsDurableConfigurationCompositionTests.cs
@@ -0,0 +1,194 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections;
+using System.Reflection;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Azure.Functions.Worker;
+using Microsoft.Azure.Functions.Worker.Builder;
+using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
+using Microsoft.Azure.Functions.Worker.Invocation;
+using Microsoft.Azure.Functions.Worker.Middleware;
+using Microsoft.Extensions.DependencyInjection;
+using Moq;
+
+namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests;
+
+///
+/// Tests that and
+/// compose when both are
+/// called on the same Functions app, instead of forcing users onto
+/// .
+///
+///
+/// Regression coverage for https://github.com/microsoft/agent-framework-durable-extension/issues/27.
+///
+public sealed class FunctionsDurableConfigurationCompositionTests
+{
+ ///
+ /// Entry points that must be routed to once both agents and
+ /// workflows are configured. Without the built-in executor the generated functions have no
+ /// implementation to run.
+ ///
+ public static TheoryData BuiltInEntryPointNames() =>
+ [
+ "AgentHttp",
+ "AgentEntity",
+ "WorkflowOrchestrationHttp",
+ "WorkflowOrchestration",
+ "WorkflowActivity",
+ "WorkflowStatusHttp",
+ ];
+
+ private static string ResolveEntryPoint(string name) => name switch
+ {
+ "AgentHttp" => BuiltInFunctions.RunAgentHttpFunctionEntryPoint,
+ "AgentEntity" => BuiltInFunctions.RunAgentEntityFunctionEntryPoint,
+ "WorkflowOrchestrationHttp" => BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint,
+ "WorkflowOrchestration" => BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint,
+ "WorkflowActivity" => BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint,
+ "WorkflowStatusHttp" => BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint,
+ _ => throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown built-in entry point."),
+ };
+
+ [Theory]
+ [MemberData(nameof(BuiltInEntryPointNames))]
+ public async Task ConfigureDurableAgentsThenWorkflows_RoutesAllBuiltInEntryPointsAsync(string entryPointName)
+ {
+ FunctionsApplicationBuilder builder = FunctionsApplication.CreateBuilder([]);
+ builder.ConfigureDurableAgents(agents => agents.AddAIAgent(new TestAgent("AgentsFirstAgent", "desc")));
+ builder.ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(BuildWorkflow("AgentsFirstWorkflow")));
+
+ await AssertRoutedToBuiltInExecutorAsync(builder, ResolveEntryPoint(entryPointName));
+ }
+
+ [Theory]
+ [MemberData(nameof(BuiltInEntryPointNames))]
+ public async Task ConfigureDurableWorkflowsThenAgents_RoutesAllBuiltInEntryPointsAsync(string entryPointName)
+ {
+ FunctionsApplicationBuilder builder = FunctionsApplication.CreateBuilder([]);
+ builder.ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(BuildWorkflow("WorkflowsFirstWorkflow")));
+ builder.ConfigureDurableAgents(agents => agents.AddAIAgent(new TestAgent("WorkflowsFirstAgent", "desc")));
+
+ await AssertRoutedToBuiltInExecutorAsync(builder, ResolveEntryPoint(entryPointName));
+ }
+
+ [Fact]
+ public void ConfigureDurableAgentsAndWorkflows_RegistersBuiltInMiddlewareOnce()
+ {
+ FunctionsApplicationBuilder builder = FunctionsApplication.CreateBuilder([]);
+ builder.ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(BuildWorkflow("OnceWorkflow")));
+ builder.ConfigureDurableAgents(agents => agents.AddAIAgent(new TestAgent("OnceAgent", "desc")));
+
+ Assert.Equal(1, builder.Services.Count(d => d.ServiceType == typeof(BuiltInFunctionExecutionMiddleware)));
+ Assert.Equal(1, builder.Services.Count(d => d.ServiceType == typeof(BuiltInFunctionExecutor)));
+ }
+
+ [Fact]
+ public void ConfigureDurableAgentsAndWorkflows_RegistersBothMetadataTransformers()
+ {
+ FunctionsApplicationBuilder builder = FunctionsApplication.CreateBuilder([]);
+ builder.ConfigureDurableAgents(agents => agents.AddAIAgent(new TestAgent("TransformerAgent", "desc")));
+ builder.ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(BuildWorkflow("TransformerWorkflow")));
+
+ List transformers = [.. builder.Services
+ .Where(d => d.ServiceType == typeof(IFunctionMetadataTransformer))
+ .Select(d => d.ImplementationType)];
+
+ Assert.Contains(typeof(DurableAgentFunctionMetadataTransformer), transformers);
+ Assert.Contains(typeof(DurableWorkflowsFunctionMetadataTransformer), transformers);
+ }
+
+ private static async Task AssertRoutedToBuiltInExecutorAsync(FunctionsApplicationBuilder builder, string entryPoint)
+ {
+ using ServiceProvider provider = builder.Services.BuildServiceProvider();
+ FunctionExecutionDelegate pipeline = BuildInvocationPipeline(builder);
+
+ TestInvocationFeatures features = new();
+ FunctionContext context = CreateContext(provider, features, entryPoint);
+
+ await pipeline(context);
+
+ IFunctionExecutor? executor = features.Get();
+ Assert.NotNull(executor);
+ Assert.IsType(executor);
+ }
+
+ ///
+ /// Builds the middleware pipeline that the Functions host would build. The pipeline is held by the
+ /// worker builder that wraps and is not otherwise reachable
+ /// before Build().
+ ///
+ private static FunctionExecutionDelegate BuildInvocationPipeline(FunctionsApplicationBuilder builder)
+ {
+ object workerBuilder = GetFieldValue(
+ builder,
+ f => typeof(IFunctionsWorkerApplicationBuilder).IsAssignableFrom(f.FieldType));
+
+ object pipelineBuilder = GetFieldValue(
+ workerBuilder,
+ f => f.FieldType.Name.StartsWith("IInvocationPipelineBuilder", StringComparison.Ordinal));
+
+ MethodInfo build = pipelineBuilder.GetType().GetMethod("Build", Type.EmptyTypes)
+ ?? throw new InvalidOperationException("Could not find the invocation pipeline Build method.");
+
+ return (FunctionExecutionDelegate)build.Invoke(pipelineBuilder, null)!;
+ }
+
+ private static object GetFieldValue(object instance, Func predicate)
+ {
+ FieldInfo field = Array.Find(
+ instance.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public),
+ f => predicate(f))
+ ?? throw new InvalidOperationException(
+ $"Could not locate the expected field on {instance.GetType().FullName}.");
+
+ return field.GetValue(instance)
+ ?? throw new InvalidOperationException(
+ $"Field '{field.Name}' on {instance.GetType().FullName} was null.");
+ }
+
+ private static FunctionContext CreateContext(
+ IServiceProvider services,
+ IInvocationFeatures features,
+ string entryPoint)
+ {
+ Mock definition = new();
+ definition.SetupGet(d => d.EntryPoint).Returns(entryPoint);
+ definition.SetupGet(d => d.Name).Returns("test-function");
+
+ Mock context = new();
+ context.SetupGet(c => c.InstanceServices).Returns(services);
+ context.SetupGet(c => c.FunctionDefinition).Returns(definition.Object);
+ context.SetupGet(c => c.Features).Returns(features);
+
+ return context.Object;
+ }
+
+ private static Workflow BuildWorkflow(string name) =>
+ new WorkflowBuilder(new FunctionExecutor("start", (_, _, _) => default))
+ .WithName(name)
+ .Build();
+
+ private sealed class TestInvocationFeatures : IInvocationFeatures
+ {
+ private readonly Dictionary _features = [];
+
+ public T? Get() => this._features.TryGetValue(typeof(T), out object? value) ? (T)value : default;
+
+ public void Set(T instance)
+ {
+ if (instance is null)
+ {
+ this._features.Remove(typeof(T));
+ }
+ else
+ {
+ this._features[typeof(T)] = instance;
+ }
+ }
+
+ public IEnumerator> GetEnumerator() => this._features.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator();
+ }
+}