mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add a file memory provider (#5315)
* Add a file memory provider * Address PR comments * Fix review comments. * Add additional unit tests * Addressing PR comments.
This commit is contained in:
committed by
GitHub
Unverified
parent
99627e41d2
commit
8dca006edd
+555
@@ -0,0 +1,555 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory;
|
||||
|
||||
public class FileMemoryProviderTests
|
||||
{
|
||||
#region Constructor Validation
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullFileStore_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new FileMemoryProvider(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithDefaults_Succeeds()
|
||||
{
|
||||
// Act
|
||||
var provider = new FileMemoryProvider(new InMemoryAgentFileStore());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithStateInitializer_Succeeds()
|
||||
{
|
||||
// Act
|
||||
var provider = new FileMemoryProvider(
|
||||
new InMemoryAgentFileStore(),
|
||||
_ => new FileMemoryState { WorkingFolder = "custom" });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ProvideAIContextAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ProvideAIContextAsync_ReturnsToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _, session) = await CreateToolsAsync();
|
||||
|
||||
// Assert - 5 tools: SaveFile, ReadFile, DeleteFile, ListFiles, SearchFiles
|
||||
Assert.Equal(5, tools.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvideAIContextAsync_ReturnsInstructionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new FileMemoryProvider(new InMemoryAgentFileStore());
|
||||
var agent = new Mock<AIAgent>().Object;
|
||||
var session = new ChatClientAgentSession();
|
||||
#pragma warning disable MAAI001
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
|
||||
// Act
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.Contains("file-based memory", result.Instructions);
|
||||
Assert.Contains("compacted", result.Instructions);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SaveFile Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SaveFile_CreatesFileAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
|
||||
// Act
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "notes.md",
|
||||
["content"] = "Test content",
|
||||
["description"] = "",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
var content = await store.ReadFileAsync("notes.md");
|
||||
Assert.Equal("Test content", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveFile_WithDescription_CreatesBothFilesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
|
||||
// Act
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "research.md",
|
||||
["content"] = "Long research content...",
|
||||
["description"] = "Summary of research findings",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
var content = await store.ReadFileAsync("research.md");
|
||||
Assert.Equal("Long research content...", content);
|
||||
var desc = await store.ReadFileAsync("research_description.md");
|
||||
Assert.Equal("Summary of research findings", desc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveFile_WithoutDescription_DeletesStaleDescriptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
|
||||
// Save with description first.
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "notes.md",
|
||||
["content"] = "Original",
|
||||
["description"] = "Old description",
|
||||
}, session);
|
||||
Assert.NotNull(await store.ReadFileAsync("notes_description.md"));
|
||||
|
||||
// Act — overwrite without description.
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "notes.md",
|
||||
["content"] = "Updated",
|
||||
}, session);
|
||||
|
||||
// Assert — stale description file is removed.
|
||||
Assert.Equal("Updated", await store.ReadFileAsync("notes.md"));
|
||||
Assert.Null(await store.ReadFileAsync("notes_description.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveFile_WithCustomState_CreatesInSubfolderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, state, session) = await CreateToolsAsync(store, _ => new FileMemoryState { WorkingFolder = "session123" });
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
|
||||
// Act
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "notes.md",
|
||||
["content"] = "Session content",
|
||||
["description"] = "",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("session123", state.WorkingFolder);
|
||||
var content = await store.ReadFileAsync("session123/notes.md");
|
||||
Assert.Equal("Session content", content);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ReadFile Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ReadFile_ExistingFile_ReturnsContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Stored content");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var readFile = GetTool(tools, "FileMemory_ReadFile");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(readFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "notes.md",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
var text = Assert.IsType<JsonElement>(result).GetString();
|
||||
Assert.Equal("Stored content", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadFile_NonExistent_ReturnsNotFoundMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _, session) = await CreateToolsAsync();
|
||||
var readFile = GetTool(tools, "FileMemory_ReadFile");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(readFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "nonexistent.md",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
var text = Assert.IsType<JsonElement>(result).GetString();
|
||||
Assert.Contains("not found", text);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DeleteFile Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFile_ExistingFile_DeletesAndReturnsConfirmationAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Content");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var deleteFile = GetTool(tools, "FileMemory_DeleteFile");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(deleteFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "notes.md",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
var text = Assert.IsType<JsonElement>(result).GetString();
|
||||
Assert.Contains("deleted", text);
|
||||
Assert.False(await store.FileExistsAsync("notes.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFile_AlsoDeletesDescriptionFileAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Content");
|
||||
await store.WriteFileAsync("notes_description.md", "Description");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var deleteFile = GetTool(tools, "FileMemory_DeleteFile");
|
||||
|
||||
// Act
|
||||
await InvokeWithRunContextAsync(deleteFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "notes.md",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
Assert.False(await store.FileExistsAsync("notes.md"));
|
||||
Assert.False(await store.FileExistsAsync("notes_description.md"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ListFiles Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_ReturnsFilesWithDescriptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Content");
|
||||
await store.WriteFileAsync("notes_description.md", "A description");
|
||||
await store.WriteFileAsync("other.md", "Other content");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var listFiles = GetTool(tools, "FileMemory_ListFiles");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(listFiles, new AIFunctionArguments(), session);
|
||||
|
||||
// Assert
|
||||
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
|
||||
Assert.Equal(2, entries.Count);
|
||||
|
||||
var notesEntry = entries.First(e => e.GetProperty("fileName").GetString() == "notes.md");
|
||||
Assert.Equal("A description", notesEntry.GetProperty("description").GetString());
|
||||
|
||||
var otherEntry = entries.First(e => e.GetProperty("fileName").GetString() == "other.md");
|
||||
Assert.False(otherEntry.TryGetProperty("description", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_HidesDescriptionFilesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Content");
|
||||
await store.WriteFileAsync("notes_description.md", "Desc");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var listFiles = GetTool(tools, "FileMemory_ListFiles");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(listFiles, new AIFunctionArguments(), session);
|
||||
|
||||
// Assert
|
||||
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
|
||||
Assert.Single(entries);
|
||||
Assert.Equal("notes.md", entries[0].GetProperty("fileName").GetString());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SearchFiles Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_FindsMatchingContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Important research findings about AI");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var searchFiles = GetTool(tools, "FileMemory_SearchFiles");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(searchFiles, new AIFunctionArguments
|
||||
{
|
||||
["regexPattern"] = "research findings",
|
||||
["filePattern"] = "",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
|
||||
Assert.Single(entries);
|
||||
Assert.Equal("notes.md", entries[0].GetProperty("fileName").GetString());
|
||||
Assert.True(entries[0].TryGetProperty("matchingLines", out var matchingLines));
|
||||
Assert.True(matchingLines.GetArrayLength() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_WithFilePattern_FiltersResultsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Important data");
|
||||
await store.WriteFileAsync("data.txt", "Important data");
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var searchFiles = GetTool(tools, "FileMemory_SearchFiles");
|
||||
|
||||
// Act
|
||||
var result = await InvokeWithRunContextAsync(searchFiles, new AIFunctionArguments
|
||||
{
|
||||
["regexPattern"] = "Important",
|
||||
["filePattern"] = "*.md",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
var entries = Assert.IsType<JsonElement>(result).EnumerateArray().ToList();
|
||||
Assert.Single(entries);
|
||||
Assert.Equal("notes.md", entries[0].GetProperty("fileName").GetString());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region State Initializer Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CustomStateInitializer_SetsWorkingFolderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (_, state, _) = await CreateToolsAsync(store, _ => new FileMemoryState { WorkingFolder = "user42" });
|
||||
|
||||
// Assert
|
||||
Assert.Equal("user42", state.WorkingFolder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultStateInitializer_UsesEmptyWorkingFolderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (_, state, _) = await CreateToolsAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(string.Empty, state.WorkingFolder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task State_PersistsAcrossInvocationsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var provider = new FileMemoryProvider(store, _ => new FileMemoryState { WorkingFolder = "persistent" });
|
||||
var agent = new Mock<AIAgent>().Object;
|
||||
var session = new ChatClientAgentSession();
|
||||
#pragma warning disable MAAI001
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
|
||||
// Act - first invocation initializes state
|
||||
await provider.InvokingAsync(context);
|
||||
session.StateBag.TryGetValue<FileMemoryState>("FileMemoryProvider", out var state1, AgentJsonUtilities.DefaultOptions);
|
||||
|
||||
// Second invocation should reuse the same folder
|
||||
await provider.InvokingAsync(context);
|
||||
session.StateBag.TryGetValue<FileMemoryState>("FileMemoryProvider", out var state2, AgentJsonUtilities.DefaultOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(state1);
|
||||
Assert.NotNull(state2);
|
||||
Assert.Equal(state1!.WorkingFolder, state2!.WorkingFolder);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Path Traversal Protection
|
||||
|
||||
[Fact]
|
||||
public async Task SaveFile_PathTraversal_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _, session) = await CreateToolsAsync();
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "../escape.md",
|
||||
["content"] = "Content",
|
||||
["description"] = "",
|
||||
}, session));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveFile_AbsolutePath_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _, session) = await CreateToolsAsync();
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "/etc/passwd",
|
||||
["content"] = "Content",
|
||||
["description"] = "",
|
||||
}, session));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveFile_DriveRootedPath_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _, session) = await CreateToolsAsync();
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "C:\\temp\\file.md",
|
||||
["content"] = "Content",
|
||||
}, session));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveFile_DoubleDotsInFileName_AllowedAsync()
|
||||
{
|
||||
// Arrange — "notes..md" is not a path traversal attempt.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
var (tools, _, session) = await CreateToolsAsync(store);
|
||||
var saveFile = GetTool(tools, "FileMemory_SaveFile");
|
||||
|
||||
// Act
|
||||
await InvokeWithRunContextAsync(saveFile, new AIFunctionArguments
|
||||
{
|
||||
["fileName"] = "notes..md",
|
||||
["content"] = "Content",
|
||||
}, session);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Content", await store.ReadFileAsync("notes..md"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static FileMemoryProvider CreateProvider(InMemoryAgentFileStore? store = null, Func<AgentSession?, FileMemoryState>? stateInitializer = null)
|
||||
{
|
||||
return new FileMemoryProvider(store ?? new InMemoryAgentFileStore(), stateInitializer);
|
||||
}
|
||||
|
||||
private static async Task<(IEnumerable<AITool> Tools, FileMemoryState State, AgentSession Session)> CreateToolsAsync(InMemoryAgentFileStore? store = null, Func<AgentSession?, FileMemoryState>? stateInitializer = null)
|
||||
{
|
||||
var provider = CreateProvider(store, stateInitializer);
|
||||
var agent = new Mock<AIAgent>().Object;
|
||||
var session = new ChatClientAgentSession();
|
||||
#pragma warning disable MAAI001
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
|
||||
session.StateBag.TryGetValue<FileMemoryState>("FileMemoryProvider", out var state, AgentJsonUtilities.DefaultOptions);
|
||||
|
||||
return (result.Tools!, state!, session);
|
||||
}
|
||||
|
||||
private static AIFunction GetTool(IEnumerable<AITool> tools, string name)
|
||||
{
|
||||
return (AIFunction)tools.First(t => t is AIFunction f && f.Name == name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a tool within a mock <see cref="AIAgent.CurrentRunContext"/> so that
|
||||
/// the tool methods can access the session via <c>AIAgent.CurrentRunContext?.Session</c>.
|
||||
/// </summary>
|
||||
/// <param name="tool">The tool to invoke.</param>
|
||||
/// <param name="arguments">The arguments to pass to the tool.</param>
|
||||
/// <param name="session">
|
||||
/// An optional session to use in the run context. When provided, ensures the tool executes
|
||||
/// against the same session whose state was initialized during <see cref="CreateToolsAsync"/>.
|
||||
/// When <see langword="null"/>, a new session is created.
|
||||
/// </param>
|
||||
private static async Task<object?> InvokeWithRunContextAsync(AIFunction tool, AIFunctionArguments arguments, AgentSession? session = null)
|
||||
{
|
||||
var agent = new Mock<AIAgent>().Object;
|
||||
session ??= new ChatClientAgentSession();
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
// Set up the ambient run context so tool methods can access the session.
|
||||
var runContext = new AgentRunContext(agent, session, messages, null);
|
||||
|
||||
// Use reflection to set the protected static CurrentRunContext property.
|
||||
var property = typeof(AIAgent).GetProperty("CurrentRunContext", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
|
||||
var setter = property!.GetSetMethod(true)!;
|
||||
var previousContext = AIAgent.CurrentRunContext;
|
||||
try
|
||||
{
|
||||
setter.Invoke(null, [runContext]);
|
||||
return await tool.InvokeAsync(arguments);
|
||||
}
|
||||
finally
|
||||
{
|
||||
setter.Invoke(null, [previousContext]);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory;
|
||||
|
||||
public class InMemoryAgentFileStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WriteAndReadFile_ReturnsContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
await store.WriteFileAsync("notes.md", "Hello world");
|
||||
var content = await store.ReadFileAsync("notes.md");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Hello world", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadFile_NonExistent_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
var content = await store.ReadFileAsync("nonexistent.md");
|
||||
|
||||
// Assert
|
||||
Assert.Null(content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFile_OverwritesExistingAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Original");
|
||||
|
||||
// Act
|
||||
await store.WriteFileAsync("notes.md", "Updated");
|
||||
var content = await store.ReadFileAsync("notes.md");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Updated", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFile_ExistingFile_ReturnsTrueAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Content");
|
||||
|
||||
// Act
|
||||
var deleted = await store.DeleteFileAsync("notes.md");
|
||||
|
||||
// Assert
|
||||
Assert.True(deleted);
|
||||
Assert.Null(await store.ReadFileAsync("notes.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFile_NonExistent_ReturnsFalseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
var deleted = await store.DeleteFileAsync("nonexistent.md");
|
||||
|
||||
// Assert
|
||||
Assert.False(deleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_ReturnsDirectChildrenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/file1.md", "Content 1");
|
||||
await store.WriteFileAsync("folder/file2.md", "Content 2");
|
||||
await store.WriteFileAsync("folder/sub/file3.md", "Content 3");
|
||||
await store.WriteFileAsync("other/file4.md", "Content 4");
|
||||
|
||||
// Act
|
||||
var files = await store.ListFilesAsync("folder");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, files.Count);
|
||||
Assert.Contains("file1.md", files);
|
||||
Assert.Contains("file2.md", files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_EmptyDirectory_ReturnsEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
var files = await store.ListFilesAsync("empty");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_RootDirectory_ReturnsRootFilesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("root.md", "Content");
|
||||
await store.WriteFileAsync("folder/nested.md", "Content");
|
||||
|
||||
// Act
|
||||
var files = await store.ListFilesAsync("");
|
||||
|
||||
// Assert
|
||||
Assert.Single(files);
|
||||
Assert.Equal("root.md", files[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_IncludesDescriptionFilesAsync()
|
||||
{
|
||||
// Arrange — the store is dumb; it returns all files including _description.md
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "Content");
|
||||
await store.WriteFileAsync("folder/notes_description.md", "Desc");
|
||||
|
||||
// Act
|
||||
var files = await store.ListFilesAsync("folder");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, files.Count);
|
||||
Assert.Contains("notes.md", files);
|
||||
Assert.Contains("notes_description.md", files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileExists_ExistingFile_ReturnsTrueAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("notes.md", "Content");
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(await store.FileExistsAsync("notes.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileExists_NonExistent_ReturnsFalseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
Assert.False(await store.FileExistsAsync("nonexistent.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_FindsMatchingContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "The quick brown fox jumps over the lazy dog");
|
||||
await store.WriteFileAsync("folder/other.md", "No match here");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "brown fox");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("notes.md", results[0].FileName);
|
||||
Assert.Contains("brown fox", results[0].Snippet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_ReturnsMatchingLineNumbersAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "Line one\nLine two with match\nLine three\nLine four with match");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "match");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal(2, results[0].MatchingLines.Count);
|
||||
Assert.Equal(2, results[0].MatchingLines[0].LineNumber);
|
||||
Assert.Equal("Line two with match", results[0].MatchingLines[0].Line);
|
||||
Assert.Equal(4, results[0].MatchingLines[1].LineNumber);
|
||||
Assert.Equal("Line four with match", results[0].MatchingLines[1].Line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_CaseInsensitiveAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "Important Data Here");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "important data");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_SupportsRegexPatternAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "Error: something went wrong\nWarning: check this\nInfo: all good");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "error|warning");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal(2, results[0].MatchingLines.Count);
|
||||
Assert.Equal(1, results[0].MatchingLines[0].LineNumber);
|
||||
Assert.Equal(2, results[0].MatchingLines[1].LineNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_SupportsRegexWithSpecialCharactersAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/code.cs", "var x = 42;\nvar y = 100;\nconst z = 7;");
|
||||
|
||||
// Act — regex matching lines starting with "var"
|
||||
var results = await store.SearchFilesAsync("folder", @"^var\b");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal(2, results[0].MatchingLines.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_WithGlobPattern_FiltersFilesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "Important data");
|
||||
await store.WriteFileAsync("folder/data.txt", "Important data");
|
||||
await store.WriteFileAsync("folder/code.cs", "Important data");
|
||||
|
||||
// Act — only search markdown files
|
||||
var results = await store.SearchFilesAsync("folder", "Important", filePattern: "*.md");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("notes.md", results[0].FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_WithGlobPattern_MultipleExtensionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "match here");
|
||||
await store.WriteFileAsync("folder/data.txt", "match here");
|
||||
await store.WriteFileAsync("folder/code.cs", "match here");
|
||||
|
||||
// Act — search both md and txt files
|
||||
var resultsMd = await store.SearchFilesAsync("folder", "match", filePattern: "*.md");
|
||||
var resultsTxt = await store.SearchFilesAsync("folder", "match", filePattern: "*.txt");
|
||||
|
||||
// Assert
|
||||
Assert.Single(resultsMd);
|
||||
Assert.Equal("notes.md", resultsMd[0].FileName);
|
||||
Assert.Single(resultsTxt);
|
||||
Assert.Equal("data.txt", resultsTxt[0].FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_WithGlobPattern_PrefixMatchAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/research_ai.md", "findings");
|
||||
await store.WriteFileAsync("folder/research_ml.md", "findings");
|
||||
await store.WriteFileAsync("folder/notes.md", "findings");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "findings", filePattern: "research*");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.All(results, r => Assert.StartsWith("research", r.FileName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_WithNullGlobPattern_SearchesAllFilesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "match");
|
||||
await store.WriteFileAsync("folder/data.txt", "match");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "match", filePattern: null);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_NoMatch_ReturnsEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "Some content");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "nonexistent query");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_IgnoresSubdirectoryFilesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteFileAsync("folder/notes.md", "Match here");
|
||||
await store.WriteFileAsync("folder/sub/deep.md", "Match here too");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "Match");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("notes.md", results[0].FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Snippet_IncludesSurroundingContextAsync()
|
||||
{
|
||||
// Arrange — place the match in the middle of a long line so ±50 chars are available.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
string padding = new('A', 60);
|
||||
string content = $"{padding}MATCH_HERE{padding}";
|
||||
await store.WriteFileAsync("folder/file.md", content);
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "MATCH_HERE");
|
||||
|
||||
// Assert — snippet should contain the match and surrounding context (up to ±50 chars).
|
||||
Assert.Single(results);
|
||||
string snippet = results[0].Snippet;
|
||||
Assert.Contains("MATCH_HERE", snippet);
|
||||
Assert.True(snippet.Length <= 50 + "MATCH_HERE".Length + 50, "Snippet should be at most ±50 chars around the match.");
|
||||
Assert.True(snippet.Length > "MATCH_HERE".Length, "Snippet should include surrounding context.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Snippet_MatchNearStartOfFileAsync()
|
||||
{
|
||||
// Arrange — match is at the very beginning, so no leading context is available.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
string trailing = new('B', 80);
|
||||
string content = $"MATCH{trailing}";
|
||||
await store.WriteFileAsync("folder/file.md", content);
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "MATCH");
|
||||
|
||||
// Assert — snippet should start at the beginning of the file.
|
||||
Assert.Single(results);
|
||||
Assert.StartsWith("MATCH", results[0].Snippet);
|
||||
Assert.True(results[0].Snippet.Length <= "MATCH".Length + 50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Snippet_MatchNearEndOfFileAsync()
|
||||
{
|
||||
// Arrange — match is at the very end, so no trailing context is available.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
string leading = new('C', 80);
|
||||
string content = $"{leading}MATCH";
|
||||
await store.WriteFileAsync("folder/file.md", content);
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "MATCH");
|
||||
|
||||
// Assert — snippet should end at the end of the file.
|
||||
Assert.Single(results);
|
||||
Assert.EndsWith("MATCH", results[0].Snippet);
|
||||
Assert.True(results[0].Snippet.Length <= 50 + "MATCH".Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Snippet_UsesFirstMatchPositionAsync()
|
||||
{
|
||||
// Arrange — "target" appears on lines 1 and 3, but the regex only matches line 3
|
||||
// because we require the word "UNIQUE" which only appears on line 3.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
const string Content = "Line one has some text\nLine two is filler\nLine three has UNIQUE_MARKER here";
|
||||
await store.WriteFileAsync("folder/file.md", Content);
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "UNIQUE_MARKER");
|
||||
|
||||
// Assert — snippet should be from around line 3, not line 1.
|
||||
Assert.Single(results);
|
||||
Assert.Contains("UNIQUE_MARKER", results[0].Snippet);
|
||||
Assert.Contains("Line three", results[0].Snippet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Snippet_CorrectForMultiLineMatchAsync()
|
||||
{
|
||||
// Arrange — match is on the second line with enough distance from line 1
|
||||
// that the ±50 char snippet window does not reach the start of the file.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
string line1 = new('X', 100);
|
||||
string line2 = new string('Y', 60) + "FIND_ME" + new string('Z', 60);
|
||||
string line3 = new('W', 100);
|
||||
string content = $"{line1}\n{line2}\n{line3}";
|
||||
await store.WriteFileAsync("folder/file.md", content);
|
||||
|
||||
// Act
|
||||
var results = await store.SearchFilesAsync("folder", "FIND_ME");
|
||||
|
||||
// Assert — snippet should contain the match from line 2.
|
||||
Assert.Single(results);
|
||||
Assert.Contains("FIND_ME", results[0].Snippet);
|
||||
|
||||
// The match is at offset 101 (line1=100 + '\n') + 60 = 161.
|
||||
// snippetStart = 161 - 50 = 111, which is well past line 1 (ends at offset 100).
|
||||
// So line 1 content should not appear in the snippet.
|
||||
Assert.DoesNotContain("XXXX", results[0].Snippet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PathNormalization_HandlesBackslashesAndTrailingSlashesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
await store.WriteFileAsync("folder\\file.md", "Content");
|
||||
var content = await store.ReadFileAsync("folder/file.md");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Content", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFile_PathTraversal_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.WriteFileAsync("../escape.md", "Content"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadFile_PathTraversal_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.ReadFileAsync("folder/../../escape.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFile_AbsolutePath_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.WriteFileAsync("/etc/passwd", "Content"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFile_DoubleDotsInFileName_AllowedAsync()
|
||||
{
|
||||
// Arrange — "notes..md" contains ".." as a substring but not as a path segment.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
await store.WriteFileAsync("notes..md", "Content");
|
||||
var content = await store.ReadFileAsync("notes..md");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Content", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFile_DriveRootedPath_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.WriteFileAsync("C:\\temp\\file.md", "Content"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_PathTraversal_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.ListFilesAsync("../other"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user