diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs index cacc237f4d..98d3a27245 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs @@ -118,9 +118,8 @@ async Task CustomChatMessageStore_UsingFactoryAndExistingExternalId_Async() // It's possible to create a new thread that uses the same chat message store id by providing // the VectorChatMessageStoreThreadDbKeyFeature in the feature collection when creating the new thread. - AgentFeatureCollection features = new(); - features.Set(new VectorChatMessageStoreThreadDbKeyFeature(messageStoreFromFactory.ThreadDbKey!)); - AgentThread resumedThread = agent.GetNewThread(features); + AgentThread resumedThread = agent.GetNewThread( + new AgentFeatureCollection().WithFeature(new VectorChatMessageStoreThreadDbKeyFeature(messageStoreFromFactory.ThreadDbKey!))); // Run the agent with the thread that stores conversation history in the vector store. Console.WriteLine(await agent.RunAsync("Now tell the same joke in the voice of a pirate, and add some emojis to the joke.", resumedThread)); @@ -149,9 +148,7 @@ async Task CustomChatMessageStore_PerThread_Async() // We can then pass the feature collection when creating a new thread. // We also have the opportunity here to pass any id that we want for storing the chat history in the vector store. VectorChatMessageStore perThreadMessageStore = new(vectorStore, "chat-history-1"); - AgentFeatureCollection features = new(); - features.Set(perThreadMessageStore); - AgentThread thread = agent.GetNewThread(features); + AgentThread thread = agent.GetNewThread(new AgentFeatureCollection().WithFeature(perThreadMessageStore)); Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", thread)); @@ -191,10 +188,13 @@ async Task CustomChatMessageStore_PerRun_Async() // If the agent doesn't support a message store, it would be ignored. // We also have the opportunity here to pass any id that we want for storing the chat history in the vector store. VectorChatMessageStore perRunMessageStore = new(vectorStore, "chat-history-1"); - AgentFeatureCollection features = new(); - features.Set(perRunMessageStore); - - Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", thread, options: new AgentRunOptions() { Features = features })); + Console.WriteLine(await agent.RunAsync( + "Tell me a joke about a pirate.", + thread, + options: new AgentRunOptions() + { + Features = new AgentFeatureCollection().WithFeature(perRunMessageStore) + })); // When serializing this thread, we'll see that it has no messagestore state, since the messagestore was not attached to the thread, // but just provided for the single run. Note that, depending on the circumstances, the thread may still contain other state, e.g. Memories, @@ -237,8 +237,9 @@ namespace SampleApp // or finally we can generate one ourselves. this.ThreadDbKey = serializedStoreState.ValueKind is JsonValueKind.String ? serializedStoreState.Deserialize() - : features?.Get()?.ThreadDbKey - ?? Guid.NewGuid().ToString("N"); + : features?.TryGet(out var threadDbKeyFeature) is true + ? threadDbKeyFeature.ThreadDbKey + : Guid.NewGuid().ToString("N"); } public string? ThreadDbKey { get; } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index 15df3ea5ae..3d1dc4282e 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -55,7 +55,12 @@ internal sealed class A2AAgent : AIAgent /// public sealed override AgentThread GetNewThread(IAgentFeatureCollection? featureCollection = null) - => new A2AAgentThread() { ContextId = featureCollection?.Get()?.ConversationId }; + => new A2AAgentThread() + { + ContextId = featureCollection?.TryGet(out var conversationIdFeature) is true + ? conversationIdFeature.ConversationId + : null + }; /// /// Get a new instance using an existing context id, to continue that conversation. diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Features/AgentFeatureCollection.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Features/AgentFeatureCollection.cs index 475f54c77e..df157f454c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Features/AgentFeatureCollection.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Features/AgentFeatureCollection.cs @@ -4,6 +4,7 @@ using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Microsoft.Shared.Diagnostics; @@ -18,9 +19,7 @@ namespace Microsoft.Agents.AI; [DebuggerTypeProxy(typeof(FeatureCollectionDebugView))] public class AgentFeatureCollection : IAgentFeatureCollection { - private static readonly KeyComparer s_featureKeyComparer = new(); - private readonly IAgentFeatureCollection? _defaults; - private readonly int _initialCapacity; + private readonly IAgentFeatureCollection? _innerCollection; private Dictionary? _features; private volatile int _containerRevision; @@ -39,59 +38,38 @@ public class AgentFeatureCollection : IAgentFeatureCollection public AgentFeatureCollection(int initialCapacity) { Throw.IfLessThan(initialCapacity, 0); - - this._initialCapacity = initialCapacity; + this._features = new(initialCapacity); } /// - /// Initializes a new instance of with the specified defaults. + /// Initializes a new instance of with the specified inner collection. /// - /// The feature defaults. - public AgentFeatureCollection(IAgentFeatureCollection defaults) + /// The inner collection. + /// + /// + /// When providing an inner collection, and if a feature is not found in this collection, + /// an attempt will be made to retrieve it from the inner collection as a fallback. + /// + /// + /// The method will only remove features from this collection + /// and not from the inner collection. When removing a feature from this collection, and + /// it exists in the inner collection, it will still be retrievable from the inner collection. + /// + /// + public AgentFeatureCollection(IAgentFeatureCollection innerCollection) { - this._defaults = defaults; + this._innerCollection = Throw.IfNull(innerCollection); } /// - public virtual int Revision + public int Revision { - get { return this._containerRevision + (this._defaults?.Revision ?? 0); } + get { return this._containerRevision + (this._innerCollection?.Revision ?? 0); } } /// public bool IsReadOnly { get { return false; } } - /// - public object? this[Type key] - { - get - { - Throw.IfNull(key); - - return this._features != null && this._features.TryGetValue(key, out var result) ? result : this._defaults?[key]; - } - set - { - Throw.IfNull(key); - - if (value == null) - { - if (this._features?.Remove(key) is true) - { - this._containerRevision++; - } - return; - } - - if (this._features == null) - { - this._features = new Dictionary(this._initialCapacity); - } - this._features[key] = value; - this._containerRevision++; - } - } - IEnumerator IEnumerable.GetEnumerator() { return this.GetEnumerator(); @@ -100,63 +78,102 @@ public class AgentFeatureCollection : IAgentFeatureCollection /// public IEnumerator> GetEnumerator() { - if (this._features != null) + if (this._features is not { Count: > 0 }) { - foreach (var pair in this._features) - { - yield return pair; - } + IEnumerable> e = ((IEnumerable>?)this._innerCollection) ?? []; + return e.GetEnumerator(); } - if (this._defaults != null) + if (this._innerCollection is null) { - // Don't return features masked by the wrapper. - foreach (var pair in this._features == null ? this._defaults : this._defaults.Except(this._features, s_featureKeyComparer)) + return this._features.GetEnumerator(); + } + + if (this._innerCollection is AgentFeatureCollection innerCollection && innerCollection._features is not { Count: > 0 }) + { + return this._features.GetEnumerator(); + } + + return YieldAll(); + + IEnumerator> YieldAll() + { + HashSet set = []; + + foreach (var entry in this._features) { - yield return pair; + set.Add(entry.Key); + yield return entry; + } + + foreach (var entry in this._innerCollection.Where(x => !set.Contains(x.Key))) + { + yield return entry; } } } /// - public TFeature? Get() + public bool TryGet([MaybeNullWhen(false)] out TFeature feature) + where TFeature : notnull { - if (typeof(TFeature).IsValueType) + if (this.TryGet(typeof(TFeature), out var obj)) { - var feature = this[typeof(TFeature)]; - if (feature is null && Nullable.GetUnderlyingType(typeof(TFeature)) is null) - { - throw new InvalidOperationException( - $"{typeof(TFeature).FullName} does not exist in the feature collection " + - $"and because it is a struct the method can't return null. Use 'AgentFeatureCollection[typeof({typeof(TFeature).FullName})] is not null' to check if the feature exists."); - } - return (TFeature?)feature; + feature = (TFeature)obj; + return true; } - return (TFeature?)this[typeof(TFeature)]; + + feature = default; + return false; } /// - public void Set(TFeature? instance) + public bool TryGet(Type type, [MaybeNullWhen(false)] out object feature) { - this[typeof(TFeature)] = instance; + if (this._features?.TryGetValue(type, out var obj) is true) + { + feature = obj; + return true; + } + + if (this._innerCollection?.TryGet(type, out var defaultFeature) is true) + { + feature = defaultFeature; + return true; + } + + feature = default; + return false; + } + + /// + public void Set(TFeature instance) + where TFeature : notnull + { + Throw.IfNull(instance); + + this._features ??= new(); + this._features[typeof(TFeature)] = instance; + this._containerRevision++; + } + + /// + public void Remove() + where TFeature : notnull + => this.Remove(typeof(TFeature)); + + /// + public void Remove(Type type) + { + if (this._features?.Remove(type) is true) + { + this._containerRevision++; + } } // Used by the debugger. Count over enumerable is required to get the correct value. private int GetCount() => this.Count(); - private sealed class KeyComparer : IEqualityComparer> - { - public bool Equals(KeyValuePair x, KeyValuePair y) - { - return x.Key.Equals(y.Key); - } - - public int GetHashCode(KeyValuePair obj) - { - return obj.Key.GetHashCode(); - } - } - private sealed class FeatureCollectionDebugView(AgentFeatureCollection features) { private readonly AgentFeatureCollection _features = features; diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Features/AgentFeatureCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Features/AgentFeatureCollectionExtensions.cs new file mode 100644 index 0000000000..95641858b7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Features/AgentFeatureCollectionExtensions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI; + +/// +/// Extension methods for . +/// +public static class AgentFeatureCollectionExtensions +{ + /// + /// Adds the specified feature to the collection and returns the collection. + /// + /// The feature key. + /// The feature collection to add the new feature to. + /// The feature to add to the collection. + /// The updated collection. + public static IAgentFeatureCollection WithFeature(this IAgentFeatureCollection features, TFeature feature) + where TFeature : notnull + { + features.Set(feature); + return features; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Features/ConversationIdAgentFeature.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Features/ConversationIdAgentFeature.cs index 6a17456c73..2cd267197f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Features/ConversationIdAgentFeature.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Features/ConversationIdAgentFeature.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI; /// An agent feature that allows providing a conversation identifier. /// /// -/// This feature allows a user to provide a specific identifier for chat history whether stored in the underlying AI service or stored in a 3rd party store. +/// This feature allows a user to provide a specific identifier for chat history when stored in the underlying AI service. /// public class ConversationIdAgentFeature { @@ -16,7 +16,7 @@ public class ConversationIdAgentFeature /// Initializes a new instance of the class with the specified thread /// identifier. /// - /// The unique identifier of the thread required by the underlying AI service or 3rd party store. Cannot be or empty. + /// The unique identifier of the thread required by the underlying AI service. Cannot be or empty. public ConversationIdAgentFeature(string conversationId) { this.ConversationId = Throw.IfNullOrWhitespace(conversationId); diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Features/IAgentFeatureCollection.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Features/IAgentFeatureCollection.cs index f2e7f38f86..dca17dc668 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Features/IAgentFeatureCollection.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Features/IAgentFeatureCollection.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; namespace Microsoft.Agents.AI; @@ -24,23 +25,48 @@ public interface IAgentFeatureCollection : IEnumerable - /// Gets or sets a given feature. Setting a null value removes the feature. + /// Attempts to retrieve a feature of the specified type. /// - /// - /// The requested feature, or null if it is not present. - object? this[Type key] { get; set; } + /// The type of the feature to retrieve. + /// When this method returns, contains the feature of type if found; otherwise, the + /// default value for the type. + /// + /// if the feature of type was successfully retrieved; + /// otherwise, . + /// + bool TryGet([MaybeNullWhen(false)] out TFeature feature) + where TFeature : notnull; /// - /// Retrieves the requested feature from the collection. + /// Attempts to retrieve a feature of the specified type. + /// + /// The type of the feature to get. + /// When this method returns, contains the feature of type if found; otherwise, the + /// default value for the type. + /// + /// if the feature of type was successfully retrieved; + /// otherwise, . + /// + bool TryGet(Type type, [MaybeNullWhen(false)] out object feature); + + /// + /// Remove a feature from the collection. /// /// The feature key. - /// The requested feature, or null if it is not present. - TFeature? Get(); + void Remove() + where TFeature : notnull; + + /// + /// Remove a feature from the collection. + /// + /// The type of the feature to remove. + void Remove(Type type); /// /// Sets the given feature in the collection. /// /// The feature key. /// The feature value. - void Set(TFeature? instance); + void Set(TFeature instance) + where TFeature : notnull; } diff --git a/dotnet/src/Microsoft.Agents.AI.CopilotStudio/CopilotStudioAgent.cs b/dotnet/src/Microsoft.Agents.AI.CopilotStudio/CopilotStudioAgent.cs index 634236269a..c689984537 100644 --- a/dotnet/src/Microsoft.Agents.AI.CopilotStudio/CopilotStudioAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.CopilotStudio/CopilotStudioAgent.cs @@ -43,7 +43,12 @@ public class CopilotStudioAgent : AIAgent /// public sealed override AgentThread GetNewThread(IAgentFeatureCollection? featureCollection = null) - => new CopilotStudioAgentThread() { ConversationId = featureCollection?.Get()?.ConversationId }; + => new CopilotStudioAgentThread() + { + ConversationId = featureCollection?.TryGet(out var conversationIdFeature) is true + ? conversationIdFeature.ConversationId + : null + }; /// /// Get a new instance using an existing conversation id, to continue that conversation. diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 207492f482..4cce2e221a 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -289,13 +289,17 @@ public sealed partial class ChatClientAgent : AIAgent public override AgentThread GetNewThread(IAgentFeatureCollection? featureCollection = null) => new ChatClientAgentThread { - ConversationId = featureCollection?.Get()?.ConversationId, + ConversationId = featureCollection?.TryGet(out var conversationIdAgentFeature) is true + ? conversationIdAgentFeature.ConversationId + : null, MessageStore = - featureCollection?.Get() - ?? this._agentOptions?.ChatMessageStoreFactory?.Invoke(new() { SerializedState = default, Features = featureCollection, JsonSerializerOptions = null }), + featureCollection?.TryGet(out var chatMessageStoreFeature) is true + ? chatMessageStoreFeature + : this._agentOptions?.ChatMessageStoreFactory?.Invoke(new() { SerializedState = default, Features = featureCollection, JsonSerializerOptions = null }), AIContextProvider = - featureCollection?.Get() - ?? this._agentOptions?.AIContextProviderFactory?.Invoke(new() { SerializedState = default, Features = featureCollection, JsonSerializerOptions = null }) + featureCollection?.TryGet(out var aIContextProviderFeature) is true + ? aIContextProviderFeature + : this._agentOptions?.AIContextProviderFactory?.Invoke(new() { SerializedState = default, Features = featureCollection, JsonSerializerOptions = null }) }; /// @@ -353,17 +357,15 @@ public sealed partial class ChatClientAgent : AIAgent /// public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, IAgentFeatureCollection? featureCollection = null) { - var chatMessageStoreFeature = featureCollection?.Get(); Func? chatMessageStoreFactory = - chatMessageStoreFeature is not null + featureCollection?.TryGet(out var chatMessageStoreFeature) is true ? (jse, jso) => chatMessageStoreFeature : this._agentOptions?.ChatMessageStoreFactory is not null ? (jse, jso) => this._agentOptions.ChatMessageStoreFactory.Invoke(new() { SerializedState = jse, Features = featureCollection, JsonSerializerOptions = jso }) : null; - var aiContextProviderFeature = featureCollection?.Get(); Func? aiContextProviderFactory = - aiContextProviderFeature is not null + featureCollection?.TryGet(out var aiContextProviderFeature) is true ? (jse, jso) => aiContextProviderFeature : this._agentOptions?.AIContextProviderFactory is not null ? (jse, jso) => this._agentOptions.AIContextProviderFactory.Invoke(new() { SerializedState = jse, Features = featureCollection, JsonSerializerOptions = jso }) @@ -644,7 +646,7 @@ public sealed partial class ChatClientAgent : AIAgent // If the caller provided an override message store via run options, we should use that instead of the message store // on the thread. - if (runOptions?.Features?.Get() is ChatMessageStore chatMessageStoreFeature) + if (runOptions?.Features?.TryGet(out var chatMessageStoreFeature) is true) { messageStore = chatMessageStoreFeature; } @@ -745,7 +747,7 @@ public sealed partial class ChatClientAgent : AIAgent // If the caller provided an override message store via run options, we should use that instead of the message store // on the thread. - if (runOptions?.Features?.Get() is ChatMessageStore chatMessageStoreFeature) + if (runOptions?.Features?.TryGet(out var chatMessageStoreFeature) is true) { messageStore = chatMessageStoreFeature; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentFeatureCollectionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentFeatureCollectionTests.cs index 7b55cfd64f..9d3a3e7c66 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentFeatureCollectionTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentFeatureCollectionTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Linq; namespace Microsoft.Agents.AI.Abstractions.UnitTests; @@ -10,98 +11,168 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests; public class AgentFeatureCollectionTests { [Fact] - public void AddedInterfaceIsReturned() + public void Feature_RoundTrips() { + // Arrange. var interfaces = new AgentFeatureCollection(); var thing = new Thing(); - interfaces[typeof(IThing)] = thing; + // Act. + interfaces.Set(thing); + Assert.True(interfaces.TryGet(out var actualThing)); - var thing2 = interfaces[typeof(IThing)]; - Assert.Equal(thing2, thing); + // Assert. + Assert.Same(actualThing, thing); + Assert.Equal(1, interfaces.Revision); } [Fact] - public void IndexerAlsoAddsItems() + public void RemoveOfT_Removes() { + // Arrange. var interfaces = new AgentFeatureCollection(); var thing = new Thing(); - interfaces[typeof(IThing)] = thing; + interfaces.Set(thing); + Assert.True(interfaces.TryGet(out _)); - Assert.Equal(interfaces[typeof(IThing)], thing); + // Act. + interfaces.Remove(); + + // Assert. + Assert.False(interfaces.TryGet(out _)); + Assert.Equal(2, interfaces.Revision); } [Fact] - public void SetNullValueRemoves() + public void Remove_Removes() { + // Arrange. var interfaces = new AgentFeatureCollection(); var thing = new Thing(); - interfaces[typeof(IThing)] = thing; - Assert.Equal(interfaces[typeof(IThing)], thing); + interfaces.Set(thing); + Assert.True(interfaces.TryGet(out _)); - interfaces[typeof(IThing)] = null; + // Act. + interfaces.Remove(typeof(IThing)); - var thing2 = interfaces[typeof(IThing)]; - Assert.Null(thing2); + // Assert. + Assert.False(interfaces.TryGet(out _)); + Assert.Equal(2, interfaces.Revision); } [Fact] - public void GetMissingStructFeatureThrows() + public void TryGetMissingFeature_ReturnsFalse() { + // Arrange. var interfaces = new AgentFeatureCollection(); - var ex = Assert.Throws(() => interfaces.Get()); - Assert.Equal("System.Int32 does not exist in the feature collection and because it is a struct the method can't return null. Use 'AgentFeatureCollection[typeof(System.Int32)] is not null' to check if the feature exists.", ex.Message); + // Act & Assert. + Assert.False(interfaces.TryGet(out var actualThing)); + Assert.Null(actualThing); } [Fact] - public void GetMissingFeatureReturnsNull() + public void Set_Null_Throws() { + // Arrange. var interfaces = new AgentFeatureCollection(); - Assert.Null(interfaces.Get()); + // Act & Assert. + Assert.Throws(() => interfaces.Set(null!)); } [Fact] - public void GetStructFeature() + public void IsReadOnly_DefaultsToFalse() { + // Arrange. var interfaces = new AgentFeatureCollection(); - const int Value = 20; - interfaces.Set(Value); - Assert.Equal(Value, interfaces.Get()); + // Act & Assert. + Assert.False(interfaces.IsReadOnly); } [Fact] - public void GetNullableStructFeatureWhenSetWithNonNullableStruct() + public void TryGetOfT_FallsBackToInnerCollection() { - var interfaces = new AgentFeatureCollection(); - const int Value = 20; - interfaces.Set(Value); - - Assert.Null(interfaces.Get()); - } - - [Fact] - public void GetNullableStructFeatureWhenSetWithNullableStruct() - { - var interfaces = new AgentFeatureCollection(); - const int Value = 20; - interfaces.Set(Value); - - Assert.Equal(Value, interfaces.Get()); - } - - [Fact] - public void GetFeature() - { - var interfaces = new AgentFeatureCollection(); + // Arrange. + var inner = new AgentFeatureCollection(); var thing = new Thing(); - interfaces.Set(thing); + inner.Set(thing); + var outer = new AgentFeatureCollection(inner); - Assert.Equal(thing, interfaces.Get()); + // Act & Assert. + Assert.True(outer.TryGet(out var actualThing)); + Assert.Same(actualThing, thing); + } + + [Fact] + public void TryGetOfT_OverridesInnerWithOuterCollection() + { + // Arrange. + var inner = new AgentFeatureCollection(); + var innerThing = new Thing(); + inner.Set(innerThing); + + var outer = new AgentFeatureCollection(inner); + var outerThing = new Thing(); + outer.Set(outerThing); + + // Act & Assert. + Assert.True(outer.TryGet(out var actualThing)); + Assert.Same(outerThing, actualThing); + } + + [Fact] + public void TryGet_FallsBackToInnerCollection() + { + // Arrange. + var inner = new AgentFeatureCollection(); + var thing = new Thing(); + inner.Set(thing); + var outer = new AgentFeatureCollection(inner); + + // Act & Assert. + Assert.True(outer.TryGet(typeof(IThing), out var actualThing)); + Assert.Same(actualThing, thing); + } + + [Fact] + public void TryGet_OverridesInnerWithOuterCollection() + { + // Arrange. + var inner = new AgentFeatureCollection(); + var innerThing = new Thing(); + inner.Set(innerThing); + + var outer = new AgentFeatureCollection(inner); + var outerThing = new Thing(); + outer.Set(outerThing); + + // Act & Assert. + Assert.True(outer.TryGet(typeof(IThing), out var actualThing)); + Assert.Same(outerThing, actualThing); + } + + [Fact] + public void Enumerate_OverridesInnerWithOuterCollection() + { + // Arrange. + var inner = new AgentFeatureCollection(); + var innerThing = new Thing(); + inner.Set(innerThing); + + var outer = new AgentFeatureCollection(inner); + var outerThing = new Thing(); + outer.Set(outerThing); + + // Act. + var items = outer.ToList(); + + // Assert. + Assert.Single(items); + Assert.Same(outerThing, items.First().Value as IThing); } private interface IThing