From 9a95e751e8c477786f8b0433392ee52227f4d119 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Mon, 10 Aug 2026 11:01:55 -0700 Subject: [PATCH 1/3] Fix AddWorkflow silently overwriting a workflow with a duplicate name AddWorkflow assigned into its backing dictionary with an indexer, so registering a second workflow under a name that was already taken replaced the first one with no exception, log, or startup failure. Because ExecutorRegistry.Register uses TryAdd (first writer wins) while the workflow dictionary took the last writer, a collision left the two registries disagreeing: the surviving workflow's topology was executed using the first workflow's executor code for any executor name the two shared. AddWorkflow now throws when a name maps to a different workflow instance, using a message consistent with the existing AddAIAgent duplicate-name error. Re-registering the same instance stays a no-op, which is required because BuildWorkflowRegistrationRecursive re-adds a sub-workflow that was also registered explicitly, and the registration walk can run more than once against the shared DurableOptions. Note that a sub-workflow cannot be shared by multiple parent workflows: the framework's Workflow.TakeOwnership rejects that at bind time. The added test therefore covers the reachable equivalent, a sub-workflow re-registered through the recursive registration walk. Fixes #50 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1feb866c-d58d-4b86-baf3-c15e18f51787 --- .../CHANGELOG.md | 2 + .../Workflows/DurableWorkflowOptions.cs | 22 +++- .../Workflows/DurableWorkflowOptionsTests.cs | 117 ++++++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowOptionsTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md index 463b792..a16da90 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md @@ -2,6 +2,8 @@ ## [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 ([#50](https://github.com/microsoft/agent-framework-durable-extension/issues/50)) + - 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/Workflows/DurableWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs index 8222cc4..7d9b916 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowOptions.cs @@ -43,12 +43,21 @@ internal DurableWorkflowOptions(DurableOptions? parentOptions = null) /// The workflow instance to add. Cannot be null. /// The options instance, so that multiple calls can be chained. /// + /// /// When a workflow is added, all executors are registered in the executor registry. /// Any AI agent executors will also be automatically registered with the /// if available. + /// + /// + /// 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. + /// /// /// Thrown when is null. - /// Thrown when the workflow does not have a valid name. + /// + /// Thrown when the workflow does not have a valid name, or when a different workflow with the same name has already been registered. + /// public DurableWorkflowOptions AddWorkflow(Workflow workflow) { ArgumentNullException.ThrowIfNull(workflow); @@ -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); diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowOptionsTests.cs new file mode 100644 index 0000000..129fc2d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowOptionsTests.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; + +/// +/// Tests for workflow registration on . +/// +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(() => 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; + options.AddWorkflow(CreateWorkflow("OrderPipeline", "StepA")); + + // Act & Assert + Assert.Throws(() => options.AddWorkflow(CreateWorkflow("orderpipeline", "StepB"))); + } + + [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(() => options.AddWorkflow(null!)); + Assert.Throws(() => options.AddWorkflow( + new WorkflowBuilder(new FunctionExecutor("Step", (_, _, _) => default)).Build())); + } + + private static void AddSubWorkflows(DurableWorkflowOptions options, Workflow workflow) + { + foreach (SubworkflowBinding binding in workflow.ReflectExecutors() + .Select(e => e.Value) + .OfType()) + { + options.AddWorkflow(binding.WorkflowInstance); + } + } + + private static Workflow CreateWorkflow(string workflowName, string executorName) => + new WorkflowBuilder(new FunctionExecutor(executorName, (_, _, _) => default)) + .WithName(workflowName) + .Build(); + + private static Workflow CreateParentWorkflow( + string workflowName, + string executorName, + Workflow subWorkflow, + string subWorkflowExecutorName) + { + FunctionExecutor start = new(executorName, (_, _, _) => default); + ExecutorBinding subWorkflowExecutor = subWorkflow.BindAsExecutor(subWorkflowExecutorName); + + return new WorkflowBuilder(start) + .WithName(workflowName) + .AddEdge(start, subWorkflowExecutor) + .Build(); + } +} From a30e92725d8fcf25e8606bee64dd607b13e29f98 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Mon, 10 Aug 2026 15:15:40 -0700 Subject: [PATCH 2/3] Encode new test file as UTF-8 with BOM to satisfy dotnet format Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1feb866c-d58d-4b86-baf3-c15e18f51787 --- .../Workflows/DurableWorkflowOptionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowOptionsTests.cs index 129fc2d..3a77b77 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowOptionsTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.DurableTask.Workflows; using Microsoft.Agents.AI.Workflows; From 55cb2a20a24c0c362c715974923d3fb72d3983c6 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Mon, 10 Aug 2026 15:19:15 -0700 Subject: [PATCH 3/3] Address PR review: link CHANGELOG entry to the PR and assert registry state Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1feb866c-d58d-4b86-baf3-c15e18f51787 --- dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md | 3 +-- .../Workflows/DurableWorkflowOptionsTests.cs | 8 ++++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md index a16da90..ecc8176 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md @@ -2,8 +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 ([#50](https://github.com/microsoft/agent-framework-durable-extension/issues/50)) - +- 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)) diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowOptionsTests.cs index 3a77b77..3a28eda 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableWorkflowOptionsTests.cs @@ -33,10 +33,14 @@ public void AddWorkflow_ThrowsWhenNameDiffersOnlyByCase() { // Arrange - workflow names are compared case-insensitively because they map to orchestration names. DurableWorkflowOptions options = new DurableOptions().Workflows; - options.AddWorkflow(CreateWorkflow("OrderPipeline", "StepA")); + Workflow first = CreateWorkflow("OrderPipeline", "StepA"); + options.AddWorkflow(first); - // Act & Assert + // Act Assert.Throws(() => options.AddWorkflow(CreateWorkflow("orderpipeline", "StepB"))); + + // Assert - the original registration is left untouched. + Assert.Same(first, Assert.Single(options.Workflows).Value); } [Fact]