diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProvider.cs index 3f482915be2..60bb3ac26cb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Memory/FoundryMemoryProvider.cs @@ -156,6 +156,11 @@ protected override async ValueTask ProvideAIContextAsync(InvokingCont } } + if (string.IsNullOrEmpty(outputMessageText)) + { + return new AIContext(); + } + return new AIContext { Messages = [new ChatMessage(ChatRole.User, outputMessageText)] diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/FoundryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/FoundryMemoryProviderTests.cs index b1696d3162f..0b55a5ea02c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/FoundryMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/FoundryMemoryProviderTests.cs @@ -1,20 +1,95 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Moq; namespace Microsoft.Agents.AI.Foundry.UnitTests.Memory; /// -/// Tests for constructor validation. +/// Tests for . /// /// -/// Since directly uses , -/// integration tests are used to verify the memory operations. These unit tests focus on: +/// A mocked HTTP transport lets these tests exercise the public provider invocation without a live Foundry project. +/// These unit tests cover: /// - Constructor parameter validation /// - State initializer validation +/// - Memory search result handling /// public sealed class FoundryMemoryProviderTests { + [Theory] + [InlineData("""{"memories":[]}""")] + [InlineData("""{"memories":[{"memory_item":{"content":""}},{"memory_item":{"content":" "}}]}""")] + public async Task InvokingAsync_WhenSearchReturnsNoUsableMemories_ReturnsOnlyInputMessagesAsync(string searchResponse) + { + // Arrange + using TestableAIProjectClient testClient = new(searchMemoriesResponse: searchResponse); + Mock loggerFactory = CreateErrorFailingLoggerFactory(); + FoundryMemoryProvider sut = new( + testClient.Client, + "store", + stateInitializer: _ => new(new FoundryMemoryProviderScope("test")), + loggerFactory: loggerFactory.Object); + AIContextProvider.InvokingContext invocation = CreateInvokingContext(); + + // Act + AIContext result = await sut.InvokingAsync(invocation); + + // Assert + Assert.Contains( + "/memory_stores/store:search_memories", + testClient.Handler.LastRequestUri!, + StringComparison.Ordinal); + ChatMessage message = Assert.Single(result.Messages!); + Assert.Equal(ChatRole.User, message.Role); + Assert.Equal("What do I prefer?", message.Text); + } + + [Fact] + public async Task InvokingAsync_WhenSearchReturnsMemories_InjectsMemoryMessageAsync() + { + // Arrange + using TestableAIProjectClient testClient = new( + searchMemoriesResponse: + """ + { + "search_id": "search-1", + "memories": [ + { + "memory_item": { + "memory_id": "memory-1", + "updated_at": 0, + "scope": "test", + "content": "The user prefers concise answers.", + "kind": "user_profile" + } + } + ] + } + """); + FoundryMemoryProvider sut = new( + testClient.Client, + "store", + stateInitializer: _ => new(new FoundryMemoryProviderScope("test"))); + AIContextProvider.InvokingContext invocation = CreateInvokingContext(); + + // Act + AIContext result = await sut.InvokingAsync(invocation); + + // Assert + ChatMessage[] messages = result.Messages!.ToArray(); + Assert.Equal(2, messages.Length); + Assert.Equal("What do I prefer?", messages[0].Text); + Assert.Equal(ChatRole.User, messages[1].Role); + Assert.Equal( + "## Memories\nConsider the following memories when answering user questions:\nThe user prefers concise answers.", + messages[1].Text); + } + [Fact] public void Constructor_Throws_WhenClientIsNull() { @@ -127,4 +202,21 @@ public void Constructor_Succeeds_WithValidParameters() // Assert Assert.NotNull(sut); } + + private static AIContextProvider.InvokingContext CreateInvokingContext() => new( + new Mock().Object, + new Mock().Object, + new AIContext { Messages = [new ChatMessage(ChatRole.User, "What do I prefer?")] }); + + private static Mock CreateErrorFailingLoggerFactory() + { + Mock mockLogger = new(MockBehavior.Strict); + mockLogger.Setup(logger => logger.IsEnabled(LogLevel.Information)).Returns(false); + mockLogger.Setup(logger => logger.IsEnabled(LogLevel.Error)).Returns(true); + + Mock loggerFactory = new(MockBehavior.Strict); + loggerFactory.Setup(factory => factory.CreateLogger(It.IsAny())).Returns(mockLogger.Object); + + return loggerFactory; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/TestableAIProjectClient.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/TestableAIProjectClient.cs index f1c4c75718b..c1ad67a3b79 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/TestableAIProjectClient.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/TestableAIProjectClient.cs @@ -88,9 +88,9 @@ public MockHttpMessageHandler( this._searchMemoriesResponse = searchMemoriesResponse ?? """{"memories":[]}"""; this._updateMemoriesResponse = updateMemoriesResponse ?? """{"update_id":"test-update-id","status":"queued"}"""; this._searchStatusCode = searchStatusCode ?? HttpStatusCode.OK; - this._updateStatusCode = updateStatusCode ?? HttpStatusCode.OK; - this._deleteStatusCode = deleteStatusCode ?? HttpStatusCode.NoContent; - this._createStoreStatusCode = createStoreStatusCode ?? HttpStatusCode.Created; + this._updateStatusCode = updateStatusCode ?? HttpStatusCode.Accepted; + this._deleteStatusCode = deleteStatusCode ?? HttpStatusCode.OK; + this._createStoreStatusCode = createStoreStatusCode ?? HttpStatusCode.OK; this._getStoreStatusCode = getStoreStatusCode ?? HttpStatusCode.NotFound; } @@ -115,27 +115,27 @@ protected override async Task SendAsync(HttpRequestMessage string path = request.RequestUri?.AbsolutePath ?? ""; // Route based on path and method - if (path.Contains("/memory-stores/") && path.Contains("/search") && request.Method == HttpMethod.Post) + if (path.Contains("/memory_stores/") && path.Contains(":search_memories") && request.Method == HttpMethod.Post) { return CreateResponse(this._searchStatusCode, this._searchMemoriesResponse); } - if (path.Contains("/memory-stores/") && path.Contains("/memories") && request.Method == HttpMethod.Post) + if (path.Contains("/memory_stores/") && path.EndsWith(":update_memories", StringComparison.Ordinal) && request.Method == HttpMethod.Post) { return CreateResponse(this._updateStatusCode, this._updateMemoriesResponse); } - if (path.Contains("/memory-stores/") && path.Contains("/scopes") && request.Method == HttpMethod.Delete) + if (path.Contains("/memory_stores/") && path.EndsWith(":delete_scope", StringComparison.Ordinal) && request.Method == HttpMethod.Post) { - return CreateResponse(this._deleteStatusCode, ""); + return CreateResponse(this._deleteStatusCode, """{"object":"memory_store.scope.deleted","name":"test-store","scope":"test-scope","deleted":true}"""); } - if (path.Contains("/memory-stores") && request.Method == HttpMethod.Post) + if (path.EndsWith("/memory_stores", StringComparison.Ordinal) && request.Method == HttpMethod.Post) { return CreateResponse(this._createStoreStatusCode, """{"name":"test-store","status":"active"}"""); } - if (path.Contains("/memory-stores/") && request.Method == HttpMethod.Get) + if (path.Contains("/memory_stores/") && request.Method == HttpMethod.Get) { return CreateResponse(this._getStoreStatusCode, """{"name":"test-store","status":"active"}"""); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/TestableAIProjectClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/TestableAIProjectClientTests.cs new file mode 100644 index 00000000000..28c2b219872 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Memory/TestableAIProjectClientTests.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Azure.AI.Projects.Memory; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Memory; + +/// Verifies that the mock transport handles requests generated by the memory SDK. +public sealed class TestableAIProjectClientTests +{ + /// Verifies the update action route and its accepted response. + [Fact] + public async Task UpdateMemoriesAsync_MatchesSdkRequestAsync() + { + // Arrange + using TestableAIProjectClient testClient = new(); + MemoryUpdateOptions options = new("test-scope"); + options.Items.Add(ResponseItem.CreateUserMessageItem("I prefer concise answers.")); + + // Act + var result = await testClient.Client.MemoryStores.UpdateMemoriesAsync("test-store", options); + + // Assert + Assert.Equal("test-update-id", result.Value.UpdateId); + Assert.Equal((int)HttpStatusCode.Accepted, result.GetRawResponse().Status); + Assert.Equal(HttpMethod.Post, testClient.Handler.LastRequestMethod); + Assert.Equal("/api/projects/test-project/memory_stores/test-store:update_memories", new Uri(testClient.Handler.LastRequestUri!).AbsolutePath); + } + + /// Verifies that deleting a scope uses POST and returns its result. + [Fact] + public async Task DeleteScopeAsync_MatchesSdkRequestAsync() + { + // Arrange + using TestableAIProjectClient testClient = new(); + + // Act + var result = await testClient.Client.MemoryStores.DeleteScopeAsync("test-store", "test-scope"); + + // Assert + Assert.True(result.Value.IsDeleted); + Assert.Equal("test-scope", result.Value.Scope); + Assert.Equal((int)HttpStatusCode.OK, result.GetRawResponse().Status); + Assert.Equal(HttpMethod.Post, testClient.Handler.LastRequestMethod); + Assert.Equal("/api/projects/test-project/memory_stores/test-store:delete_scope", new Uri(testClient.Handler.LastRequestUri!).AbsolutePath); + } + + /// Verifies the collection route used to create a memory store. + [Fact] + public async Task CreateMemoryStoreAsync_MatchesSdkRequestAsync() + { + // Arrange + using TestableAIProjectClient testClient = new(); + MemoryStoreDefaultDefinition definition = new("chat-model", "embedding-model"); + + // Act + var result = await testClient.Client.MemoryStores.CreateMemoryStoreAsync("test-store", definition); + + // Assert + Assert.Equal("test-store", result.Value.Name); + Assert.Equal((int)HttpStatusCode.OK, result.GetRawResponse().Status); + Assert.Equal(HttpMethod.Post, testClient.Handler.LastRequestMethod); + Assert.Equal("/api/projects/test-project/memory_stores", new Uri(testClient.Handler.LastRequestUri!).AbsolutePath); + } + + /// Verifies that retrieving a memory store uses the configured mock result. + [Fact] + public async Task GetMemoryStoreAsync_MatchesSdkRequestAsync() + { + // Arrange + using TestableAIProjectClient testClient = new(getStoreStatusCode: HttpStatusCode.OK); + + // Act + var result = await testClient.Client.MemoryStores.GetMemoryStoreAsync("test-store"); + + // Assert + Assert.Equal("test-store", result.Value.Name); + Assert.Equal((int)HttpStatusCode.OK, result.GetRawResponse().Status); + Assert.Equal(HttpMethod.Get, testClient.Handler.LastRequestMethod); + Assert.Equal("/api/projects/test-project/memory_stores/test-store", new Uri(testClient.Handler.LastRequestUri!).AbsolutePath); + } +}