diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs index fa1639543a..fca3fb99b3 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs @@ -70,14 +70,17 @@ AgentThread resumedThread = agent.DeserializeThread(serializedThread); // Run the agent with the thread that stores conversation history in the vector store a second time. Console.WriteLine(await agent.RunAsync("Now tell the same joke in the voice of a pirate, and add some emojis to the joke.", resumedThread)); +// We can access the VectorChatMessageStore via the thread's GetService method if we need to read the key under which threads are stored. +var messageStore = resumedThread.GetService()!; +Console.WriteLine($"\nThread is stored in vector store under key: {messageStore.ThreadDbKey}"); + namespace SampleApp { /// - /// A sample implementation of that stores chat messages in a vector store. + /// A sample implementation of that stores chat messages in a vector store. /// - internal sealed class VectorChatMessageStore : IChatMessageStore + internal sealed class VectorChatMessageStore : ChatMessageStore { - private string? _threadId; private readonly VectorStore _vectorStore; public VectorChatMessageStore(VectorStore vectorStore, JsonElement serializedStoreState, JsonSerializerOptions? jsonSerializerOptions = null) @@ -87,35 +90,37 @@ namespace SampleApp if (serializedStoreState.ValueKind is JsonValueKind.String) { // Here we can deserialize the thread id so that we can access the same messages as before the suspension. - this._threadId = serializedStoreState.Deserialize(); + this.ThreadDbKey = serializedStoreState.Deserialize(); } } - public async Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken) + public string? ThreadDbKey { get; private set; } + + public override async Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken) { - this._threadId ??= Guid.NewGuid().ToString(); + this.ThreadDbKey ??= Guid.NewGuid().ToString(); var collection = this._vectorStore.GetCollection("ChatHistory"); await collection.EnsureCollectionExistsAsync(cancellationToken); await collection.UpsertAsync(messages.Select(x => new ChatHistoryItem() { - Key = this._threadId + x.MessageId, + Key = this.ThreadDbKey + x.MessageId, Timestamp = DateTimeOffset.UtcNow, - ThreadId = this._threadId, + ThreadId = this.ThreadDbKey, SerializedMessage = JsonSerializer.Serialize(x), MessageText = x.Text }), cancellationToken); } - public async Task> GetMessagesAsync(CancellationToken cancellationToken) + public override async Task> GetMessagesAsync(CancellationToken cancellationToken) { var collection = this._vectorStore.GetCollection("ChatHistory"); await collection.EnsureCollectionExistsAsync(cancellationToken); var records = await collection .GetAsync( - x => x.ThreadId == this._threadId, 10, + x => x.ThreadId == this.ThreadDbKey, 10, new() { OrderBy = x => x.Descending(y => y.Timestamp) }, cancellationToken) .ToListAsync(cancellationToken); @@ -126,9 +131,9 @@ namespace SampleApp return messages; } - public ValueTask SerializeStateAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + public override ValueTask SerializeStateAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => // We have to serialize the thread id, so that on deserialization we can retrieve the messages using the same thread id. - new(JsonSerializer.SerializeToElement(this._threadId)); + new(JsonSerializer.SerializeToElement(this.ThreadDbKey)); /// /// The data structure used to store chat history items in the vector store. diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step13_Memory/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step13_Memory/Program.cs index c00ac4073b..c97da72c68 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step13_Memory/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step13_Memory/Program.cs @@ -62,20 +62,22 @@ Console.WriteLine(await agent.RunAsync("What is my name and age?", deserializedT Console.WriteLine("\n>> Read memories from memory component\n"); -// It's possible to access the memory component via the thread's AIContextProvider property. -var userInfo = ((deserializedThread as ChatClientAgentThread)!.AIContextProvider as UserInfoMemory)!.UserInfo; +// It's possible to access the memory component via the thread's GetService method. +var userInfo = deserializedThread.GetService()?.UserInfo; // Output the user info that was captured by the memory component. -Console.WriteLine($"MEMORY - User Name: {userInfo.UserName}"); -Console.WriteLine($"MEMORY - User Age: {userInfo.UserAge}"); +Console.WriteLine($"MEMORY - User Name: {userInfo?.UserName}"); +Console.WriteLine($"MEMORY - User Age: {userInfo?.UserAge}"); Console.WriteLine("\n>> Use new thread with previously created memories\n"); // It is also possible to set the memories in a memory component on an individual thread. // This is useful if we want to start a new thread, but have it share the same memories as a previous thread. -// For this scenario, we have to know the underlying agent thread type and the memory component type. var newThread = agent.GetNewThread(); -((newThread as ChatClientAgentThread)!.AIContextProvider as UserInfoMemory)!.UserInfo = userInfo; +if (userInfo is not null && newThread.GetService() is UserInfoMemory newThreadMemory) +{ + newThreadMemory.UserInfo = userInfo; +} // Invoke the agent and output the text result. // This time the agent should remember the user's name and use it in the response. @@ -150,12 +152,6 @@ namespace SampleApp { return new ValueTask(JsonSerializer.SerializeToElement(this.UserInfo, jsonSerializerOptions)); } - - public override ValueTask DeserializeAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - { - this.UserInfo = serializedState.Deserialize(jsonSerializerOptions) ?? new UserInfo(); - return default; - } } internal sealed class UserInfo diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowMessageStore.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowMessageStore.cs index f83507c948..fc3e465cbe 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowMessageStore.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowMessageStore.cs @@ -10,7 +10,7 @@ using Microsoft.Extensions.AI.Agents; namespace Microsoft.Agents.Workflows; -internal sealed class WorkflowMessageStore : IChatMessageStore +internal sealed class WorkflowMessageStore : ChatMessageStore { private int _bookmark; private readonly List _chatMessages = []; @@ -46,14 +46,14 @@ internal sealed class WorkflowMessageStore : IChatMessageStore internal void AddMessages(params ChatMessage[] messages) => this._chatMessages.AddRange(messages); - public Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken) + public override Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken) { this._chatMessages.AddRange(messages); return Task.CompletedTask; } - public Task> GetMessagesAsync(CancellationToken cancellationToken) => Task.FromResult>(this._chatMessages.AsReadOnly()); + public override Task> GetMessagesAsync(CancellationToken cancellationToken) => Task.FromResult>(this._chatMessages.AsReadOnly()); public IEnumerable GetFromBookmark() { @@ -65,7 +65,7 @@ internal sealed class WorkflowMessageStore : IChatMessageStore public void UpdateBookmark() => this._bookmark = this._chatMessages.Count; - public ValueTask SerializeStateAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + public override ValueTask SerializeStateAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { StoreState state = new() { diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIContextProvider.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIContextProvider.cs index d54a6b6d1b..af9243aba6 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIContextProvider.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIContextProvider.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI.Agents; @@ -52,18 +53,35 @@ public abstract class AIContextProvider return default; } - /// - /// Deserializes the state contained in the provided into the properties on this object. - /// - /// A representing the state of the object. - /// Optional settings for customizing the JSON deserialization process. - /// The to monitor for cancellation requests. The default is . - /// A that completes when the state has been deserialized. - public virtual ValueTask DeserializeAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + /// Asks the for an object of the specified type . + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the , + /// including itself or any services it might be wrapping. + /// + public virtual object? GetService(Type serviceType, object? serviceKey = null) { - return default; + _ = Throw.IfNull(serviceType); + + return serviceKey is null && serviceType.IsInstanceOfType(this) + ? this + : null; } + /// Asks the for an object of type . + /// The type of the object to be retrieved. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the , + /// including itself or any services it might be wrapping. + /// + public TService? GetService(object? serviceKey = null) + => this.GetService(typeof(TService), serviceKey) is TService service ? service : default; + /// /// Contains the event context provided to . /// diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/ChatMessageStore.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/ChatMessageStore.cs new file mode 100644 index 0000000000..3ba7a75440 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/ChatMessageStore.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI.Agents; + +/// +/// Defines methods for storing and retrieving chat messages associated with a specific thread. +/// +/// +/// Implementations of this interface are responsible for managing the storage of chat messages, +/// including handling large volumes of data by truncating or summarizing messages as necessary. +/// +public abstract class ChatMessageStore +{ + /// + /// Gets all the messages from the store that should be used for the next agent invocation. + /// + /// The to monitor for cancellation requests. The default is . + /// A collection of chat messages. + /// + /// + /// Messages are returned in ascending chronological order, with the oldest message first. + /// + /// + /// If the messages stored in the store become very large, it is up to the store to + /// truncate, summarize or otherwise limit the number of messages returned. + /// + /// + /// When using implementations of , a new one should be created for each thread + /// since they may contain state that is specific to a thread. + /// + /// + public abstract Task> GetMessagesAsync(CancellationToken cancellationToken = default); + + /// + /// Adds messages to the store. + /// + /// The messages to add. + /// The to monitor for cancellation requests. The default is . + /// An async task. + public abstract Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default); + + /// + /// Serializes the current object's state to a using the specified serialization options. + /// + /// The JSON serialization options to use. + /// The to monitor for cancellation requests. The default is . + /// A representation of the object's state. + public abstract ValueTask SerializeStateAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default); + + /// Asks the for an object of the specified type . + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the , + /// including itself or any services it might be wrapping. + /// + public virtual object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return serviceKey is null && serviceType.IsInstanceOfType(this) + ? this + : null; + } + + /// Asks the for an object of type . + /// The type of the object to be retrieved. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the , + /// including itself or any services it might be wrapping. + /// + public TService? GetService(object? serviceKey = null) + => this.GetService(typeof(TService), serviceKey) is TService service ? service : default; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/IChatMessageStore.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/IChatMessageStore.cs deleted file mode 100644 index 5990002fe1..0000000000 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/IChatMessageStore.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Extensions.AI.Agents; - -/// -/// Defines methods for storing and retrieving chat messages associated with a specific thread. -/// -/// -/// Implementations of this interface are responsible for managing the storage of chat messages, -/// including handling large volumes of data by truncating or summarizing messages as necessary. -/// -public interface IChatMessageStore -{ - /// - /// Gets all the messages from the store that should be used for the next agent invocation. - /// - /// The to monitor for cancellation requests. The default is . - /// A collection of chat messages. - /// - /// - /// Messages are returned in ascending chronological order, with the oldest message first. - /// - /// - /// If the messages stored in the store become very large, it is up to the store to - /// truncate, summarize or otherwise limit the number of messages returned. - /// - /// - /// When using implementations of , a new one should be created for each thread - /// since they may contain state that is specific to a thread. - /// - /// - Task> GetMessagesAsync(CancellationToken cancellationToken = default); - - /// - /// Adds messages to the store. - /// - /// The messages to add. - /// The to monitor for cancellation requests. The default is . - /// An async task. - Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default); - - /// - /// Serializes the current object's state to a using the specified serialization options. - /// - /// The JSON serialization options to use. - /// The to monitor for cancellation requests. The default is . - /// A representation of the object's state. - ValueTask SerializeStateAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default); -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryAgentThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryAgentThread.cs index 97654b2c0e..59c8a30558 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryAgentThread.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryAgentThread.cs @@ -85,6 +85,10 @@ public abstract class InMemoryAgentThread : AgentThread return JsonSerializer.SerializeToElement(state, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InMemoryAgentThreadState))); } + /// + public override object? GetService(Type serviceType, object? serviceKey = null) => + base.GetService(serviceType, serviceKey) ?? this.MessageStore?.GetService(serviceType, serviceKey); + /// protected internal override Task MessagesReceivedAsync(IEnumerable newMessages, CancellationToken cancellationToken = default) => this.MessageStore.AddMessagesAsync(newMessages, cancellationToken); diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryChatMessageStore.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryChatMessageStore.cs index 81662c000d..0a2d525308 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryChatMessageStore.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryChatMessageStore.cs @@ -13,7 +13,7 @@ namespace Microsoft.Extensions.AI.Agents; /// /// Represents an in-memory store for chat messages associated with a specific thread. /// -public sealed class InMemoryChatMessageStore : IList, IChatMessageStore +public sealed class InMemoryChatMessageStore : ChatMessageStore, IList { private List _messages; @@ -96,7 +96,7 @@ public sealed class InMemoryChatMessageStore : IList, IChatMessageS } /// - public async Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken) + public override async Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken) { _ = Throw.IfNull(messages); @@ -109,7 +109,7 @@ public sealed class InMemoryChatMessageStore : IList, IChatMessageS } /// - public async Task> GetMessagesAsync(CancellationToken cancellationToken) + public override async Task> GetMessagesAsync(CancellationToken cancellationToken) { if (this.ReducerTriggerEvent is ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null) { @@ -120,7 +120,7 @@ public sealed class InMemoryChatMessageStore : IList, IChatMessageS } /// - public ValueTask SerializeStateAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + public override ValueTask SerializeStateAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { StoreState state = new() { diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs index d60553e187..f85fa6f10c 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs @@ -262,7 +262,7 @@ public sealed class ChatClientAgent : AIAgent /// public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) { - Func? chatMessageStoreFactory = this._agentOptions?.ChatMessageStoreFactory is null ? + Func? chatMessageStoreFactory = this._agentOptions?.ChatMessageStoreFactory is null ? null : (jse, jso) => this._agentOptions.ChatMessageStoreFactory.Invoke(new() { SerializedState = jse, JsonSerializerOptions = jso }); diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentOptions.cs index 5fd73a2f86..a831896396 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentOptions.cs @@ -76,10 +76,10 @@ public class ChatClientAgentOptions public ChatOptions? ChatOptions { get; set; } /// - /// Gets or sets a factory function to create an instance of + /// Gets or sets a factory function to create an instance of /// which will be used to store chat messages for this agent. /// - public Func? ChatMessageStoreFactory { get; set; } + public Func? ChatMessageStoreFactory { get; set; } /// /// Gets or sets a factory function to create an instance of @@ -135,14 +135,14 @@ public class ChatClientAgentOptions } /// - /// Context object passed to the to create a new instance of . + /// Context object passed to the to create a new instance of . /// public class ChatMessageStoreFactoryContext { /// /// Gets or sets the serialized state of the chat message store, if any. /// - /// if there is no state, e.g. when the is first created. + /// if there is no state, e.g. when the is first created. public JsonElement SerializedState { get; set; } /// diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentThread.cs index 1543bc0196..a469b1268c 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentThread.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentThread.cs @@ -16,7 +16,7 @@ namespace Microsoft.Extensions.AI.Agents; public class ChatClientAgentThread : AgentThread { private string? _conversationId; - private IChatMessageStore? _messageStore; + private ChatMessageStore? _messageStore; /// /// Initializes a new instance of the class. @@ -30,12 +30,12 @@ public class ChatClientAgentThread : AgentThread /// /// A representing the serialized state of the thread. /// Optional settings for customizing the JSON deserialization process. - /// An optional factory function to create a custom . + /// An optional factory function to create a custom . /// An optional factory function to create a custom . internal ChatClientAgentThread( JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null, - Func? chatMessageStoreFactory = null, + Func? chatMessageStoreFactory = null, Func? aiContextProviderFactory = null) { if (serializedThreadState.ValueKind != JsonValueKind.Object) @@ -77,7 +77,7 @@ public class ChatClientAgentThread : AgentThread /// /// This property may be null in the following cases: /// - /// The thread stores messages via the and not in the agent service. + /// The thread stores messages via the and not in the agent service. /// This thread object is new and a server managed thread has not yet been created in the agent service. /// /// @@ -110,7 +110,7 @@ public class ChatClientAgentThread : AgentThread } /// - /// Gets or sets the used by this thread, for cases where messages should be stored in a custom location. + /// Gets or sets the used by this thread, for cases where messages should be stored in a custom location. /// /// /// @@ -121,12 +121,12 @@ public class ChatClientAgentThread : AgentThread /// /// This property may be null in the following cases: /// - /// The thread stores messages in the agent service and just has an id to the remove thread, instead of in an . - /// This thread object is new it is not yet clear whether it will be backed by a server managed thread or an . + /// The thread stores messages in the agent service and just has an id to the remove thread, instead of in an . + /// This thread object is new it is not yet clear whether it will be backed by a server managed thread or an . /// /// /// - public IChatMessageStore? MessageStore + public ChatMessageStore? MessageStore { get => this._messageStore; internal set @@ -179,12 +179,12 @@ public class ChatClientAgentThread : AgentThread } /// - public override object? GetService(Type serviceType, object? serviceKey = null) - { - return serviceType == typeof(AgentThreadMetadata) ? - new AgentThreadMetadata(this.ConversationId) : - base.GetService(serviceType, serviceKey); - } + public override object? GetService(Type serviceType, object? serviceKey = null) => + serviceType == typeof(AgentThreadMetadata) + ? new AgentThreadMetadata(this.ConversationId) + : base.GetService(serviceType, serviceKey) + ?? this.AIContextProvider?.GetService(serviceType, serviceKey) + ?? this.MessageStore?.GetService(serviceType, serviceKey); /// protected override async Task MessagesReceivedAsync(IEnumerable newMessages, CancellationToken cancellationToken = default) diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AIContextProviderTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AIContextProviderTests.cs index 3b26743969..00ec74663f 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AIContextProviderTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AIContextProviderTests.cs @@ -27,15 +27,6 @@ public class AIContextProviderTests Assert.Equal(default, actual); } - [Fact] - public async Task DeserializeAsync_ReturnsCompletedTaskAsync() - { - var provider = new TestAIContextProvider(); - var element = default(JsonElement); - var actual = provider.DeserializeAsync(element); - Assert.Equal(default, actual); - } - [Fact] public void InvokingContext_Constructor_ThrowsForNullMessages() { @@ -48,6 +39,122 @@ public class AIContextProviderTests Assert.Throws(() => new AIContextProvider.InvokedContext(null!)); } + #region GetService Method Tests + + /// + /// Verify that GetService returns the context provider itself when requesting the exact context provider type. + /// + [Fact] + public void GetService_RequestingExactContextProviderType_ReturnsContextProvider() + { + // Arrange + var contextProvider = new TestAIContextProvider(); + + // Act + var result = contextProvider.GetService(typeof(TestAIContextProvider)); + + // Assert + Assert.NotNull(result); + Assert.Same(contextProvider, result); + } + + /// + /// Verify that GetService returns the context provider itself when requesting the base AIContextProvider type. + /// + [Fact] + public void GetService_RequestingAIContextProviderType_ReturnsContextProvider() + { + // Arrange + var contextProvider = new TestAIContextProvider(); + + // Act + var result = contextProvider.GetService(typeof(AIContextProvider)); + + // Assert + Assert.NotNull(result); + Assert.Same(contextProvider, result); + } + + /// + /// Verify that GetService returns null when requesting an unrelated type. + /// + [Fact] + public void GetService_RequestingUnrelatedType_ReturnsNull() + { + // Arrange + var contextProvider = new TestAIContextProvider(); + + // Act + var result = contextProvider.GetService(typeof(string)); + + // Assert + Assert.Null(result); + } + + /// + /// Verify that GetService returns null when a service key is provided, even for matching types. + /// + [Fact] + public void GetService_WithServiceKey_ReturnsNull() + { + // Arrange + var contextProvider = new TestAIContextProvider(); + + // Act + var result = contextProvider.GetService(typeof(TestAIContextProvider), "some-key"); + + // Assert + Assert.Null(result); + } + + /// + /// Verify that GetService throws ArgumentNullException when serviceType is null. + /// + [Fact] + public void GetService_WithNullServiceType_ThrowsArgumentNullException() + { + // Arrange + var contextProvider = new TestAIContextProvider(); + + // Act & Assert + Assert.Throws(() => contextProvider.GetService(null!)); + } + + /// + /// Verify that GetService generic method works correctly. + /// + [Fact] + public void GetService_Generic_ReturnsCorrectType() + { + // Arrange + var contextProvider = new TestAIContextProvider(); + + // Act + var result = contextProvider.GetService(); + + // Assert + Assert.NotNull(result); + Assert.Same(contextProvider, result); + } + + /// + /// Verify that GetService generic method returns null for unrelated types. + /// + [Fact] + public void GetService_Generic_ReturnsNullForUnrelatedType() + { + // Arrange + var contextProvider = new TestAIContextProvider(); + + // Act + var result = contextProvider.GetService(); + + // Assert + Assert.Null(result); + } + + #endregion + private sealed class TestAIContextProvider : AIContextProvider { public override ValueTask InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) @@ -59,10 +166,5 @@ public class AIContextProviderTests { return await base.SerializeAsync(jsonSerializerOptions, cancellationToken); } - - public override async ValueTask DeserializeAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - { - await base.DeserializeAsync(serializedState, jsonSerializerOptions, cancellationToken); - } } } diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/ChatMessageStoreTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/ChatMessageStoreTests.cs new file mode 100644 index 0000000000..65b1cf00df --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/ChatMessageStoreTests.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI.Agents.Abstractions.UnitTests; + +/// +/// Contains tests for the class. +/// +public class ChatMessageStoreTests +{ + #region GetService Method Tests + + [Fact] + public void GetService_RequestingExactStoreType_ReturnsStore() + { + var store = new TestChatMessageStore(); + var result = store.GetService(typeof(TestChatMessageStore)); + Assert.NotNull(result); + Assert.Same(store, result); + } + + [Fact] + public void GetService_RequestingBaseStoreType_ReturnsStore() + { + var store = new TestChatMessageStore(); + var result = store.GetService(typeof(ChatMessageStore)); + Assert.NotNull(result); + Assert.Same(store, result); + } + + [Fact] + public void GetService_RequestingUnrelatedType_ReturnsNull() + { + var store = new TestChatMessageStore(); + var result = store.GetService(typeof(string)); + Assert.Null(result); + } + + [Fact] + public void GetService_WithServiceKey_ReturnsNull() + { + var store = new TestChatMessageStore(); + var result = store.GetService(typeof(TestChatMessageStore), "some-key"); + Assert.Null(result); + } + + [Fact] + public void GetService_WithNullServiceType_ThrowsArgumentNullException() + { + var store = new TestChatMessageStore(); + Assert.Throws(() => store.GetService(null!)); + } + + [Fact] + public void GetService_Generic_ReturnsCorrectType() + { + var store = new TestChatMessageStore(); + var result = store.GetService(); + Assert.NotNull(result); + Assert.Same(store, result); + } + + [Fact] + public void GetService_Generic_ReturnsNullForUnrelatedType() + { + var store = new TestChatMessageStore(); + var result = store.GetService(); + Assert.Null(result); + } + + #endregion + + private sealed class TestChatMessageStore : ChatMessageStore + { + public override Task> GetMessagesAsync(CancellationToken cancellationToken = default) + => Task.FromResult>(Array.Empty()); + + public override Task AddMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public override ValueTask SerializeStateAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => default; + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryAgentThreadTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryAgentThreadTests.cs index 2e46bc67fa..64729684c4 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryAgentThreadTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryAgentThreadTests.cs @@ -131,6 +131,22 @@ public class InMemoryAgentThreadTests #endregion + #region GetService Tests + + [Fact] + public void GetService_RequestingChatMessageStore_ReturnsChatMessageStore() + { + // Arrange + var thread = new TestInMemoryAgentThread(); + + // Act & Assert + Assert.NotNull(thread.GetService(typeof(ChatMessageStore))); + Assert.Same(thread.GetMessageStore(), thread.GetService(typeof(ChatMessageStore))); + Assert.Same(thread.GetMessageStore(), thread.GetService(typeof(InMemoryChatMessageStore))); + } + + #endregion + // Sealed test subclass to expose protected members for testing private sealed class TestInMemoryAgentThread : InMemoryAgentThread { diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentOptionsTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentOptionsTests.cs index 56ffbc6435..da60448711 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentOptionsTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentOptionsTests.cs @@ -164,7 +164,7 @@ public class ChatClientAgentOptionsTests const string Name = "Test name"; const string Description = "Test description"; var tools = new List { AIFunctionFactory.Create(() => "test") }; - static IChatMessageStore ChatMessageStoreFactory(ChatClientAgentOptions.ChatMessageStoreFactoryContext ctx) => new Mock().Object; + static ChatMessageStore ChatMessageStoreFactory(ChatClientAgentOptions.ChatMessageStoreFactoryContext ctx) => new Mock().Object; static AIContextProvider AIContextProviderFactory(ChatClientAgentOptions.AIContextProviderFactoryContext ctx) => new Mock().Object; var original = new ChatClientAgentOptions(Instructions, Name, Description, tools) diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentTests.cs index cbc7fe941b..3f32a1fbe5 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentTests.cs @@ -1707,7 +1707,7 @@ public class ChatClientAgentTests { // Arrange var mockChatClient = new Mock(); - var mockStore = new Mock(); + var mockStore = new Mock(); var factoryCalled = false; var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs index ddd8453844..736f75e06e 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs @@ -308,7 +308,7 @@ public class ChatClientAgentThreadTests new Dictionary { ["Key"] = "TestValue" }, TestJsonSerializerContext.Default.DictionaryStringObject); - var messageStoreMock = new Mock(); + var messageStoreMock = new Mock(); messageStoreMock .Setup(m => m.SerializeStateAsync(options, It.IsAny())) .ReturnsAsync(storeStateElement); @@ -333,6 +333,45 @@ public class ChatClientAgentThreadTests #endregion Serialize Tests + #region GetService Tests + + [Fact] + public void GetService_RequestingAIContextProvider_ReturnsAIContextProvider() + { + // Arrange + var thread = new ChatClientAgentThread(); + var mockProvider = new Mock(); + mockProvider + .Setup(m => m.GetService(It.Is(x => x == typeof(AIContextProvider)), null)) + .Returns(mockProvider.Object); + thread.AIContextProvider = mockProvider.Object; + + // Act + var result = thread.GetService(typeof(AIContextProvider)); + + // Assert + Assert.NotNull(result); + Assert.Same(mockProvider.Object, result); + } + + [Fact] + public void GetService_RequestingChatMessageStore_ReturnsChatMessageStore() + { + // Arrange + var thread = new ChatClientAgentThread(); + var messageStore = new InMemoryChatMessageStore(); + thread.MessageStore = messageStore; + + // Act + var result = thread.GetService(typeof(ChatMessageStore)); + + // Assert + Assert.NotNull(result); + Assert.Same(messageStore, result); + } + + #endregion + private sealed class MessageSendingAgent : AIAgent { public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)