Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingCont
}
}

if (string.IsNullOrEmpty(outputMessageText))
{
return new AIContext();
}

return new AIContext
{
Messages = [new ChatMessage(ChatRole.User, outputMessageText)]
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests for <see cref="FoundryMemoryProvider"/> constructor validation.
/// Tests for <see cref="FoundryMemoryProvider"/>.
/// </summary>
/// <remarks>
/// Since <see cref="FoundryMemoryProvider"/> directly uses <see cref="Azure.AI.Projects.AIProjectClient"/>,
/// 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
/// </remarks>
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<ILoggerFactory> 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()
{
Expand Down Expand Up @@ -127,4 +202,21 @@ public void Constructor_Succeeds_WithValidParameters()
// Assert
Assert.NotNull(sut);
}

private static AIContextProvider.InvokingContext CreateInvokingContext() => new(
new Mock<AIAgent>().Object,
new Mock<AgentSession>().Object,
new AIContext { Messages = [new ChatMessage(ChatRole.User, "What do I prefer?")] });

private static Mock<ILoggerFactory> CreateErrorFailingLoggerFactory()
{
Mock<ILogger> mockLogger = new(MockBehavior.Strict);
mockLogger.Setup(logger => logger.IsEnabled(LogLevel.Information)).Returns(false);
mockLogger.Setup(logger => logger.IsEnabled(LogLevel.Error)).Returns(true);

Mock<ILoggerFactory> loggerFactory = new(MockBehavior.Strict);
loggerFactory.Setup(factory => factory.CreateLogger(It.IsAny<string>())).Returns(mockLogger.Object);

return loggerFactory;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -115,27 +115,27 @@ protected override async Task<HttpResponseMessage> 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)
Comment thread
quifox marked this conversation as resolved.
{
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"}""");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Verifies that the mock transport handles requests generated by the memory SDK.</summary>
public sealed class TestableAIProjectClientTests
{
/// <summary>Verifies the update action route and its accepted response.</summary>
[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);
}

/// <summary>Verifies that deleting a scope uses POST and returns its result.</summary>
[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);
}

/// <summary>Verifies the collection route used to create a memory store.</summary>
[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);
}

/// <summary>Verifies that retrieving a memory store uses the configured mock result.</summary>
[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);
}
}
Loading