Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]

- Fixed `AddWorkflow` silently overwriting an existing workflow registered under the same name, which left the workflow and executor registries inconsistent. Registering a different workflow under a name that is already taken now throws, while re-registering the same workflow instance remains a no-op ([#66](https://github.com/microsoft/agent-framework-durable-extension/pull/66))
- 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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,21 @@ internal DurableWorkflowOptions(DurableOptions? parentOptions = null)
/// <param name="workflow">The workflow instance to add. Cannot be null.</param>
/// <returns>The options instance, so that multiple calls can be chained.</returns>
/// <remarks>
/// <para>
/// When a workflow is added, all executors are registered in the executor registry.
/// Any AI agent executors will also be automatically registered with the
/// <see cref="DurableAgentsOptions"/> if available.
/// </para>
/// <para>
/// Workflow names must be unique because they identify the orchestration that runs the workflow.
/// Adding the same workflow instance more than once is a no-op, so a sub-workflow that is also
/// registered explicitly is not reported as a conflict when it is discovered during registration.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="workflow"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when the workflow does not have a valid name.</exception>
/// <exception cref="ArgumentException">
/// Thrown when the workflow does not have a valid name, or when a different workflow with the same name has already been registered.
/// </exception>
public DurableWorkflowOptions AddWorkflow(Workflow workflow)
{
ArgumentNullException.ThrowIfNull(workflow);
Expand All @@ -58,6 +67,17 @@ public DurableWorkflowOptions AddWorkflow(Workflow workflow)
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
}

if (this._workflows.TryGetValue(workflow.Name, out Workflow? existingWorkflow))
{
if (!ReferenceEquals(existingWorkflow, workflow))
{
throw new ArgumentException($"A workflow with name '{workflow.Name}' has already been registered.", nameof(workflow));
}

// The same instance was already registered, so its executors are registered too.
return this;
}

this._workflows[workflow.Name] = workflow;
this.RegisterWorkflowExecutors(workflow);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright (c) Microsoft. All rights reserved.

using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;

namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows;

/// <summary>
/// Tests for workflow registration on <see cref="DurableWorkflowOptions"/>.
/// </summary>
public sealed class DurableWorkflowOptionsTests
{
[Fact]
public void AddWorkflow_ThrowsWhenDifferentWorkflowUsesRegisteredName()
{
// Arrange
DurableWorkflowOptions options = new DurableOptions().Workflows;
Workflow first = CreateWorkflow("OrderPipeline", "StepA");
Workflow second = CreateWorkflow("OrderPipeline", "StepB");

options.AddWorkflow(first);

// Act
ArgumentException ex = Assert.Throws<ArgumentException>(() => options.AddWorkflow(second));

// Assert
Assert.Contains("has already been registered", ex.Message, StringComparison.Ordinal);
Assert.Same(first, options.Workflows["OrderPipeline"]);
}

[Fact]
public void AddWorkflow_ThrowsWhenNameDiffersOnlyByCase()
{
// Arrange - workflow names are compared case-insensitively because they map to orchestration names.
DurableWorkflowOptions options = new DurableOptions().Workflows;
Workflow first = CreateWorkflow("OrderPipeline", "StepA");
options.AddWorkflow(first);

// Act
Assert.Throws<ArgumentException>(() => options.AddWorkflow(CreateWorkflow("orderpipeline", "StepB")));

// Assert - the original registration is left untouched.
Assert.Same(first, Assert.Single(options.Workflows).Value);
}

[Fact]
public void AddWorkflow_IsIdempotentForSameInstance()
{
// Arrange
DurableWorkflowOptions options = new DurableOptions().Workflows;
Workflow workflow = CreateWorkflow("OrderPipeline", "Step");

// Act
options.AddWorkflow(workflow);
options.AddWorkflow(workflow);

// Assert
Assert.Single(options.Workflows);
Assert.Same(workflow, options.Workflows["OrderPipeline"]);
}

[Fact]
public void AddWorkflow_AllowsSubWorkflowThatIsAlsoRegisteredExplicitly()
{
// Arrange - registering a parent and its sub-workflow explicitly means the recursive registration
// walk re-adds the same sub-workflow instance.
DurableWorkflowOptions options = new DurableOptions().Workflows;
Workflow subWorkflow = CreateWorkflow("SharedSub", "SubStep");
Workflow parent = CreateParentWorkflow("Parent", "ParentStep", subWorkflow, "Sub");

// Act
options.AddWorkflow(subWorkflow);
options.AddWorkflow(parent);
AddSubWorkflows(options, parent);
AddSubWorkflows(options, parent);

// Assert
Assert.Equal(2, options.Workflows.Count);
Assert.Same(subWorkflow, options.Workflows["SharedSub"]);
}

[Fact]
public void AddWorkflow_ThrowsWhenWorkflowIsNullOrUnnamed()
{
DurableWorkflowOptions options = new DurableOptions().Workflows;

Assert.Throws<ArgumentNullException>(() => options.AddWorkflow(null!));
Assert.Throws<ArgumentException>(() => options.AddWorkflow(
new WorkflowBuilder(new FunctionExecutor<string>("Step", (_, _, _) => default)).Build()));
}

private static void AddSubWorkflows(DurableWorkflowOptions options, Workflow workflow)
{
foreach (SubworkflowBinding binding in workflow.ReflectExecutors()
.Select(e => e.Value)
.OfType<SubworkflowBinding>())
{
options.AddWorkflow(binding.WorkflowInstance);
}
}

private static Workflow CreateWorkflow(string workflowName, string executorName) =>
new WorkflowBuilder(new FunctionExecutor<string>(executorName, (_, _, _) => default))
.WithName(workflowName)
.Build();

private static Workflow CreateParentWorkflow(
string workflowName,
string executorName,
Workflow subWorkflow,
string subWorkflowExecutorName)
{
FunctionExecutor<string> start = new(executorName, (_, _, _) => default);
ExecutorBinding subWorkflowExecutor = subWorkflow.BindAsExecutor(subWorkflowExecutorName);

return new WorkflowBuilder(start)
.WithName(workflowName)
.AddEdge(start, subWorkflowExecutor)
.Build();
}
}
Loading