.NET: [BREAKING] Simplify TextSearchProvider construction and improve Mem0Provider scoping. (#1905)

* Simplify TextSearchProvider construction and improve Mem0Provider scoping

* Fixing indentation.
This commit is contained in:
westey
2025-11-05 13:00:18 +00:00
committed by GitHub
parent bb8ef466de
commit 5e38c63455
12 changed files with 251 additions and 287 deletions
@@ -45,8 +45,8 @@ public sealed class Mem0ProviderTests : IDisposable
// Arrange
var question = new ChatMessage(ChatRole.User, "What is my name?");
var input = new ChatMessage(ChatRole.User, "Hello, my name is Caoimhe.");
var options = new Mem0ProviderOptions { ThreadId = "it-thread-1", UserId = "it-user-1" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ThreadId = "it-thread-1", UserId = "it-user-1" };
var sut = new Mem0Provider(this._httpClient, storageScope);
await sut.ClearStoredMemoriesAsync();
var ctxBefore = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question }));
@@ -69,8 +69,8 @@ public sealed class Mem0ProviderTests : IDisposable
// Arrange
var question = new ChatMessage(ChatRole.User, "What is your name?");
var assistantIntro = new ChatMessage(ChatRole.Assistant, "Hello, I'm a friendly assistant and my name is Caoimhe.");
var options = new Mem0ProviderOptions { AgentId = "it-agent-1" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { AgentId = "it-agent-1" };
var sut = new Mem0Provider(this._httpClient, storageScope);
await sut.ClearStoredMemoriesAsync();
var ctxBefore = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question }));
@@ -93,8 +93,8 @@ public sealed class Mem0ProviderTests : IDisposable
// Arrange
var question = new ChatMessage(ChatRole.User, "What is your name?");
var assistantIntro = new ChatMessage(ChatRole.Assistant, "I'm an AI tutor and my name is Caoimhe.");
var sut1 = new Mem0Provider(this._httpClient, new Mem0ProviderOptions { AgentId = "it-agent-a" });
var sut2 = new Mem0Provider(this._httpClient, new Mem0ProviderOptions { AgentId = "it-agent-b" });
var sut1 = new Mem0Provider(this._httpClient, new Mem0ProviderScope { AgentId = "it-agent-a" });
var sut2 = new Mem0Provider(this._httpClient, new Mem0ProviderScope { AgentId = "it-agent-b" });
await sut1.ClearStoredMemoriesAsync();
await sut2.ClearStoredMemoriesAsync();
@@ -48,35 +48,35 @@ public sealed class Mem0ProviderTests : IDisposable
using HttpClient client = new();
// Act & Assert
var ex = Assert.Throws<ArgumentException>(() => new Mem0Provider(client));
var ex = Assert.Throws<ArgumentException>(() => new Mem0Provider(client, new Mem0ProviderScope() { ThreadId = "tid" }));
Assert.StartsWith("The HttpClient BaseAddress must be set for Mem0 operations.", ex.Message);
}
[Fact]
public void Constructor_Defaults_Scopes()
public void Constructor_Throws_WhenNoStorageScopeValueIsSet()
{
// Arrange & Act
var sut = new Mem0Provider(this._httpClient);
// Assert
Assert.Null(sut.ApplicationId);
Assert.Null(sut.AgentId);
Assert.Null(sut.ThreadId);
Assert.Null(sut.UserId);
// Act & Assert
var ex = Assert.Throws<ArgumentException>(() => new Mem0Provider(this._httpClient, new Mem0ProviderScope()));
Assert.StartsWith("At least one of ApplicationId, AgentId, ThreadId, or UserId must be provided for the storage scope.", ex.Message);
}
[Fact]
public void DeserializingConstructor_Defaults_Scopes()
public void Constructor_Throws_WhenNoSearchScopeValueIsSet()
{
// Arrange & Act
var jsonElement = JsonSerializer.SerializeToElement(new object(), Mem0JsonUtilities.DefaultOptions);
var sut = new Mem0Provider(this._httpClient, jsonElement);
// Act & Assert
var ex = Assert.Throws<ArgumentException>(() => new Mem0Provider(this._httpClient, new Mem0ProviderScope() { ThreadId = "tid" }, new Mem0ProviderScope()));
Assert.StartsWith("At least one of ApplicationId, AgentId, ThreadId, or UserId must be provided for the search scope.", ex.Message);
}
// Assert
Assert.Null(sut.ApplicationId);
Assert.Null(sut.AgentId);
Assert.Null(sut.ThreadId);
Assert.Null(sut.UserId);
[Fact]
public void DeserializingConstructor_Throws_WithEmptyJsonElement()
{
// Arrange
var jsonElement = JsonSerializer.SerializeToElement(new object(), Mem0JsonUtilities.DefaultOptions);
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => new Mem0Provider(this._httpClient, jsonElement));
Assert.StartsWith("The Mem0Provider state did not contain the required scope properties.", ex.Message);
}
[Fact]
@@ -84,14 +84,14 @@ public sealed class Mem0ProviderTests : IDisposable
{
// Arrange
this._handler.EnqueueJsonResponse("[ { \"id\": \"1\", \"memory\": \"Name is Caoimhe\", \"hash\": \"h\", \"metadata\": null, \"score\": 0.9, \"created_at\": \"2023-01-01T00:00:00Z\", \"updated_at\": null, \"user_id\": \"u\", \"app_id\": null, \"agent_id\": \"agent\", \"session_id\": \"thread\" } ]");
var options = new Mem0ProviderOptions
var storageScope = new Mem0ProviderScope
{
ApplicationId = "app",
AgentId = "agent",
ThreadId = "thread",
UserId = "user"
};
var sut = new Mem0Provider(this._httpClient, options, this._loggerFactoryMock.Object);
var sut = new Mem0Provider(this._httpClient, storageScope, loggerFactory: this._loggerFactoryMock.Object);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "What is my name?") });
// Act
@@ -137,8 +137,8 @@ public sealed class Mem0ProviderTests : IDisposable
this._handler.EnqueueEmptyOk(); // For first CreateMemory
this._handler.EnqueueEmptyOk(); // For second CreateMemory
this._handler.EnqueueEmptyOk(); // For third CreateMemory
var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, storageScope);
var requestMessages = new List<ChatMessage>
{
@@ -168,8 +168,8 @@ public sealed class Mem0ProviderTests : IDisposable
public async Task InvokedAsync_PersistsNothingForFailedRequestAsync()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, storageScope);
var requestMessages = new List<ChatMessage>
{
@@ -189,8 +189,8 @@ public sealed class Mem0ProviderTests : IDisposable
public async Task InvokedAsync_ShouldNotThrow_WhenStorageFailsAsync()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, options, this._loggerFactoryMock.Object);
var storageScope = new Mem0ProviderScope { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, storageScope, loggerFactory: this._loggerFactoryMock.Object);
this._handler.EnqueueEmptyInternalServerError();
var requestMessages = new List<ChatMessage>
@@ -222,8 +222,8 @@ public sealed class Mem0ProviderTests : IDisposable
public async Task ClearStoredMemoriesAsync_SendsDeleteWithQueryAsync()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" };
var sut = new Mem0Provider(this._httpClient, storageScope);
this._handler.EnqueueEmptyOk(); // for DELETE
// Act
@@ -235,80 +235,39 @@ public sealed class Mem0ProviderTests : IDisposable
}
[Fact]
public void Properties_Roundtrip()
public void Serialize_RoundTripsScopes()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" };
var sut = new Mem0Provider(this._httpClient, options);
// Assert
Assert.Equal("app", sut.ApplicationId);
Assert.Equal("agent", sut.AgentId);
Assert.Equal("thread", sut.ThreadId);
Assert.Equal("user", sut.UserId);
// Act
sut.ApplicationId = "app2";
sut.AgentId = "agent2";
sut.ThreadId = "thread2";
sut.UserId = "user2";
// Assert
Assert.Equal("app2", sut.ApplicationId);
Assert.Equal("agent2", sut.AgentId);
Assert.Equal("thread2", sut.ThreadId);
Assert.Equal("user2", sut.UserId);
}
[Fact]
public void Serialize_Deserialize_Roundtrips()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" };
var sut = new Mem0Provider(this._httpClient, options);
// Act
var stateElement = sut.Serialize();
var sut2 = new Mem0Provider(this._httpClient, stateElement);
// Assert
Assert.Equal("app", sut.ApplicationId);
Assert.Equal("agent", sut.AgentId);
Assert.Equal("thread", sut.ThreadId);
Assert.Equal("user", sut.UserId);
Assert.Equal("app", sut2.ApplicationId);
Assert.Equal("agent", sut2.AgentId);
Assert.Equal("thread", sut2.ThreadId);
Assert.Equal("user", sut2.UserId);
}
[Fact]
public void Serialize_RoundTripsCustomContextPrompt()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user", ContextPrompt = "Custom:" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ApplicationId = "app", AgentId = "agent", ThreadId = "thread", UserId = "user" };
var sut = new Mem0Provider(this._httpClient, storageScope, options: new() { ContextPrompt = "Custom:" }, loggerFactory: this._loggerFactoryMock.Object);
// Act
var stateElement = sut.Serialize();
using JsonDocument doc = JsonDocument.Parse(stateElement.GetRawText());
Assert.Equal("Custom:", doc.RootElement.GetProperty("contextPrompt").GetString());
var storageScopeElement = doc.RootElement.GetProperty("storageScope");
Assert.Equal("app", storageScopeElement.GetProperty("applicationId").GetString());
Assert.Equal("agent", storageScopeElement.GetProperty("agentId").GetString());
Assert.Equal("thread", storageScopeElement.GetProperty("threadId").GetString());
Assert.Equal("user", storageScopeElement.GetProperty("userId").GetString());
var sut2 = new Mem0Provider(this._httpClient, stateElement);
var stateElement2 = sut2.Serialize();
// Assert
using JsonDocument doc2 = JsonDocument.Parse(stateElement2.GetRawText());
Assert.Equal("Custom:", doc2.RootElement.GetProperty("contextPrompt").GetString());
var storageScopeElement2 = doc2.RootElement.GetProperty("storageScope");
Assert.Equal("app", storageScopeElement2.GetProperty("applicationId").GetString());
Assert.Equal("agent", storageScopeElement2.GetProperty("agentId").GetString());
Assert.Equal("thread", storageScopeElement2.GetProperty("threadId").GetString());
Assert.Equal("user", storageScopeElement2.GetProperty("userId").GetString());
}
[Fact]
public void Serialize_DoesNotIncludeDefaultContextPrompt()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app" };
var sut = new Mem0Provider(this._httpClient, options);
var storageScope = new Mem0ProviderScope { ApplicationId = "app" };
var sut = new Mem0Provider(this._httpClient, storageScope);
// Act
var stateElement = sut.Serialize();
@@ -318,23 +277,12 @@ public sealed class Mem0ProviderTests : IDisposable
Assert.False(doc.RootElement.TryGetProperty("contextPrompt", out _));
}
[Fact]
public async Task InvokingAsync_Throws_WhenNoScopesAsync()
{
// Arrange
var sut = new Mem0Provider(this._httpClient, new Mem0ProviderOptions());
var ctx = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Test") });
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => sut.InvokingAsync(ctx).AsTask());
}
[Fact]
public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app" };
var provider = new Mem0Provider(this._httpClient, options, loggerFactory: this._loggerFactoryMock.Object);
var storageScope = new Mem0ProviderScope { ApplicationId = "app" };
var provider = new Mem0Provider(this._httpClient, storageScope, loggerFactory: this._loggerFactoryMock.Object);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -58,7 +58,7 @@ public sealed class TextSearchProviderTests
ContextPrompt = overrideContextPrompt,
CitationsPrompt = overrideCitationsPrompt
};
var provider = new TextSearchProvider(SearchDelegateAsync, options, withLogging ? this._loggerFactoryMock.Object : null);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options, withLogging ? this._loggerFactoryMock.Object : null);
var invokingContext = new AIContextProvider.InvokingContext(new[]
{
@@ -135,7 +135,7 @@ public sealed class TextSearchProviderTests
FunctionToolName = overrideName,
FunctionToolDescription = overrideDescription
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -154,7 +154,7 @@ public sealed class TextSearchProviderTests
public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync()
{
// Arrange
var provider = new TextSearchProvider(this.FailingSearchAsync, loggerFactory: this._loggerFactoryMock.Object);
var provider = new TextSearchProvider(this.FailingSearchAsync, default, null, loggerFactory: this._loggerFactoryMock.Object);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -195,7 +195,7 @@ public sealed class TextSearchProviderTests
ContextPrompt = overrideContextPrompt,
CitationsPrompt = overrideCitationsPrompt
};
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// Act
var formatted = await provider.SearchAsync("Sample user question?", CancellationToken.None);
@@ -247,7 +247,7 @@ public sealed class TextSearchProviderTests
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
ContextFormatter = r => $"Custom formatted context with {r.Count} results."
};
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -281,7 +281,7 @@ public sealed class TextSearchProviderTests
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
ContextFormatter = r => string.Join(",", r.Select(x => ((RawPayload)x.RawRepresentation!).Id))
};
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -298,7 +298,7 @@ public sealed class TextSearchProviderTests
{
// Arrange
var options = new TextSearchProviderOptions { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke };
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
@@ -327,7 +327,7 @@ public sealed class TextSearchProviderTests
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed.
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// Populate memory with more messages than the limit (A,B,C,D) -> should retain B,C,D
var initialMessages = new[]
@@ -367,7 +367,7 @@ public sealed class TextSearchProviderTests
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed.
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// Populate memory with more messages than the limit (A,B,C,D) -> should retain B,C,D
var initialMessages = new[]
@@ -407,7 +407,7 @@ public sealed class TextSearchProviderTests
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// First memory update (A,B)
await provider.InvokedAsync(new(new[]
@@ -449,7 +449,7 @@ public sealed class TextSearchProviderTests
capturedInput = input;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed for this test.
}
var provider = new TextSearchProvider(SearchDelegateAsync, options);
var provider = new TextSearchProvider(SearchDelegateAsync, default, null, options);
// Populate memory with mixed roles; only Assistant messages (A1,A2) should be retained.
var initialMessages = new[]
@@ -486,7 +486,7 @@ public sealed class TextSearchProviderTests
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 3
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
// Act
var state = provider.Serialize();
@@ -506,7 +506,7 @@ public sealed class TextSearchProviderTests
RecentMessageMemoryLimit = 3,
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
var messages = new[]
{
new ChatMessage(ChatRole.User, "M1"),
@@ -536,7 +536,7 @@ public sealed class TextSearchProviderTests
RecentMessageMemoryLimit = 4,
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
};
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
var provider = new TextSearchProvider(this.NoResultSearchAsync, default, null, options);
var messages = new[]
{
new ChatMessage(ChatRole.User, "A"),
@@ -571,7 +571,7 @@ public sealed class TextSearchProviderTests
public async Task Deserialize_WithChangedLowerLimit_ShouldTruncateToNewLimitAsync()
{
// Arrange
var initialProvider = new TextSearchProvider(this.NoResultSearchAsync, new TextSearchProviderOptions
var initialProvider = new TextSearchProvider(this.NoResultSearchAsync, default, null, new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 5,