.NET: Harness: Improve prompts and add FileSystem store (#5365)

* Harness: Improve prompts and add FileSystem store

* Address PR comments
This commit is contained in:
westey
2026-04-21 10:39:17 +01:00
committed by GitHub
Unverified
parent 8dca006edd
commit 7f661e8524
4 changed files with 699 additions and 13 deletions
@@ -26,23 +26,28 @@ using SampleApp;
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
// Create a compaction strategy based on the model's context window.
// gpt-5.4: 1,050,000 token context window, 128,000 max output tokens.
// Defaults: tool result eviction at 50% of input budget, truncation at 80%.
var compactionStrategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 1_050_000,
maxOutputTokens: 128_000);
maxContextWindowTokens: MaxContextWindowTokens,
maxOutputTokens: MaxOutputTokens);
// Create an OpenAIClient that communicates with the Foundry responses service and get an IChatClient with stored output disabled
// so that chat history is managed locally by the agent framework.
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
OpenAIClientOptions clientOptions = new() { Endpoint = new Uri(endpoint) };
OpenAIClientOptions clientOptions = new() { Endpoint = new Uri(endpoint), RetryPolicy = new ClientRetryPolicy(3) };
IChatClient chatClient = new OpenAIClient(new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), clientOptions)
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName)
.AsBuilder()
.UseFunctionInvocation()
.UsePerServiceCallChatHistoryPersistence()
.UseAIContextProviders(new CompactionProvider(compactionStrategy))
.Build();
@@ -53,7 +58,7 @@ var webBrowsingTools = new WebBrowsingTools();
// and research-focused instructions including the mandatory planning workflow.
var instructions =
"""
You are a research assistant. When given a research topic, research it thoroughly using web search and web browsing. Don't rely on your own knowledge use the tools available to you to find up-to-date information.
You are a research assistant. When given a research topic, research it thoroughly using web search and web browsing. Use your knowledge to form good search queries and hypotheses, but always verify claims with the tools available to you rather than relying on memory alone.
**Mandatory planning workflow**
@@ -64,13 +69,17 @@ var instructions =
1. When asking for clarification and you have specific options in mind, present them to the user with numbers, so they can respond with the number instead of having to retype the entire response.
2. Always also allow the user to respond with free-form text in case they want to provide information or context that you didn't specifically ask for.
3. Create one or more todo items.
4. Present the plan to the user.
5. Ask for approval to switch to execute mode and process the plan.
6. When approval is granted, always switch to execute mode, execute the plan and complete the todos.
4. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes.
5. Present the plan to the user.
6. Ask for approval to switch to execute mode and process the plan.
7. When approval is granted, always switch to execute mode, execute the plan and complete the todos.
8. In execute mode, work autonomously use your best judgement to make decisions and keep progressing without asking the user questions. The goal is to have a complete, useful result ready when the user returns.
9. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable option, note your choice, and keep going.
10. Continue working, thinking and calling tools until you have the research result for the user.
Explain your reasoning and thought process as you work through the tasks.
Explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process.
Don't call many tools in a row without providing some explanation in between to help the user understand what you're doing and why.
When calling many tools in a row, provide an explanation to the user after each 4 tool calls (or fewer) to help the user understand what you're doing and why.
Do not answer the underlying question before the plan has been presented and approved.
This rule applies even when the answer seems obvious or the task seems small.
For short requests, use a brief micro-plan rather than skipping planning.
@@ -79,9 +88,39 @@ var instructions =
- greetings,
- pure acknowledgments,
- clarification questions needed to form the plan,
- follow-up questions about results you have already presented,
- meta-discussion about the workflow itself.
When the task is complete, switch back to plan mode for the next request, even if the next request is just a short question.
**Todo management**
Mark each todo complete as you finish it so the list stays current.
If a todo turns out to be unnecessary or is blocked, remove it and briefly explain why.
**Research quality**
Consult multiple sources when possible and cross-reference key claims.
When sources disagree, note the discrepancy and explain which source you consider more reliable and why.
If a web page fails to load or a search returns irrelevant results, try alternative search queries or sources before moving on.
Track your sources you will need them when presenting results.
**Presenting results**
When presenting your final findings:
- Use clear sections with headings for each major topic or sub-question.
- Cite your sources inline (e.g., "According to [source name](URL), ...").
- End with a brief summary of key takeaways.
- Save the final research report to file memory so it survives compaction and can be referenced later.
**File memory**
When you download web pages or receive large amounts of data, save them to file memory using the FileMemory_SaveFile tool.
This ensures the data remains accessible even if older context is compacted or truncated during long research sessions.
Use descriptive file names (e.g., "openai_pricing_page.md") and include a brief description for large files.
Also save intermediate notes and findings as you go this helps with long multi-step research where early findings inform later steps.
Before starting new research, check file memory with FileMemory_ListFiles and FileMemory_SearchFiles for relevant prior downloads.
When a temporary file is no longer needed, delete it to keep file memory tidy.
""";
AIAgent agent = new ChatClientAgent(
@@ -90,7 +129,16 @@ AIAgent agent = new ChatClientAgent(
{
Name = "ResearchAgent",
Description = "A research assistant that plans and executes research tasks.",
AIContextProviders = [new TodoProvider(), new AgentModeProvider(), new FileMemoryProvider(new InMemoryAgentFileStore())],
AIContextProviders =
[
new TodoProvider(),
new AgentModeProvider(),
new FileMemoryProvider(
new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
(_) => new FileMemoryState() { WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid().ToString() })
],
RequirePerServiceCallChatHistoryPersistence = true,
UseProvidedChatClientAsIs = true,
ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
{
ChatReducer = compactionStrategy.AsChatReducer(),
@@ -101,10 +149,10 @@ AIAgent agent = new ChatClientAgent(
// This matches gpt-5.4's max output tokens, and should be adjusted depending on the model used and expected response length.
MaxOutputTokens = 128_000,
Instructions = instructions,
Reasoning = new() { Effort = ReasoningEffort.High },
Reasoning = new() { Effort = ReasoningEffort.Medium },
Tools = [ResponseTool.CreateWebSearchTool().AsAITool(), .. webBrowsingTools.Tools],
},
});
// Run the interactive console session using the shared HarnessConsole helper.
await HarnessConsole.RunAgentAsync(agent, title: "Research Assistant", userPrompt: "Enter a research topic to get started.", maxContextWindowTokens: 1_050_000, maxOutputTokens: 128_000);
await HarnessConsole.RunAgentAsync(agent, title: "Research Assistant", userPrompt: "Enter a research topic to get started.", maxContextWindowTokens: MaxContextWindowTokens, maxOutputTokens: MaxOutputTokens);
@@ -97,8 +97,8 @@ public sealed class AgentModeProvider : AIContextProvider
string instructions = $"""
You are currently operating in "{state.CurrentMode}" mode.
Available modes:
- "plan": Use this mode when analyzing requirements, breaking down tasks, and creating plans.
- "execute": Use this mode when implementing changes, writing code, and carrying out planned work.
- "plan": Use this mode when analyzing requirements, breaking down tasks, and creating plans. This is the interactive mode ask clarifying questions, discuss options, and get user approval before proceeding.
- "execute": Use this mode when carrying out approved plans. Work autonomously using your best judgement do not ask the user questions or wait for feedback. Make reasonable decisions on your own so that there is a complete, useful result when the user returns. If you encounter ambiguity, choose the most reasonable option and note your choice.
Use the SetMode tool to switch between modes as your work progresses. Only use SetMode if the user explicitly instructs you to change modes.
Use the GetMode tool to check your current operating mode.
""";
@@ -0,0 +1,305 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A file-system-backed implementation of <see cref="AgentFileStore"/> that stores files on disk
/// under a configurable root directory.
/// </summary>
/// <remarks>
/// <para>
/// All paths passed to this store are resolved relative to the root directory provided
/// at construction time. Lexical path traversal attempts (for example, via <c>..</c> segments
/// or absolute paths) are rejected with an <see cref="ArgumentException"/>.
/// </para>
/// <para>
/// The root directory is created automatically if it does not already exist.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileSystemAgentFileStore : AgentFileStore
{
/// <summary>
/// The canonical full path of the root directory, always ending with a directory separator.
/// </summary>
private readonly string _rootPath;
/// <summary>
/// Initializes a new instance of the <see cref="FileSystemAgentFileStore"/> class.
/// </summary>
/// <param name="rootDirectory">
/// The root directory under which all files are stored. Created if it does not exist.
/// </param>
public FileSystemAgentFileStore(string rootDirectory)
{
_ = Throw.IfNullOrWhitespace(rootDirectory);
// Canonicalize the root and ensure it ends with a separator for prefix comparison.
string fullRoot = Path.GetFullPath(rootDirectory);
if (!fullRoot.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) &&
!fullRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString(), StringComparison.Ordinal))
{
fullRoot += Path.DirectorySeparatorChar;
}
this._rootPath = fullRoot;
Directory.CreateDirectory(fullRoot);
}
/// <inheritdoc />
public override async Task WriteFileAsync(string path, string content, CancellationToken cancellationToken = default)
{
string fullPath = this.ResolveSafePath(path);
// Ensure the parent directory exists.
string? parentDir = Path.GetDirectoryName(fullPath);
if (parentDir is not null)
{
Directory.CreateDirectory(parentDir);
}
#if NET8_0_OR_GREATER
await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
#else
using var writer = new StreamWriter(fullPath, false, Encoding.UTF8);
await writer.WriteAsync(content).ConfigureAwait(false);
#endif
}
/// <inheritdoc />
public override async Task<string?> ReadFileAsync(string path, CancellationToken cancellationToken = default)
{
string fullPath = this.ResolveSafePath(path);
if (!File.Exists(fullPath))
{
return null;
}
#if NET8_0_OR_GREATER
return await File.ReadAllTextAsync(fullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
#else
using var reader = new StreamReader(fullPath, Encoding.UTF8);
return await reader.ReadToEndAsync().ConfigureAwait(false);
#endif
}
/// <inheritdoc />
public override Task<bool> DeleteFileAsync(string path, CancellationToken cancellationToken = default)
{
string fullPath = this.ResolveSafePath(path);
if (!File.Exists(fullPath))
{
return Task.FromResult(false);
}
File.Delete(fullPath);
return Task.FromResult(true);
}
/// <inheritdoc />
public override Task<IReadOnlyList<string>> ListFilesAsync(string directory, CancellationToken cancellationToken = default)
{
string fullDir = this.ResolveSafeDirectoryPath(directory);
if (!Directory.Exists(fullDir))
{
return Task.FromResult<IReadOnlyList<string>>([]);
}
var files = Directory.GetFiles(fullDir)
.Select(Path.GetFileName)
.Where(name => name is not null)
.ToList();
return Task.FromResult<IReadOnlyList<string>>(files!);
}
/// <inheritdoc />
public override Task<bool> FileExistsAsync(string path, CancellationToken cancellationToken = default)
{
string fullPath = this.ResolveSafePath(path);
return Task.FromResult(File.Exists(fullPath));
}
/// <inheritdoc />
public override async Task<IReadOnlyList<FileSearchResult>> SearchFilesAsync(
string directory,
string regexPattern,
string? filePattern = null,
CancellationToken cancellationToken = default)
{
string fullDir = this.ResolveSafeDirectoryPath(directory);
if (!Directory.Exists(fullDir))
{
return [];
}
// Compile the regex with a timeout to guard against catastrophic backtracking (ReDoS).
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5));
Matcher? matcher = filePattern is not null ? CreateGlobMatcher(filePattern) : null;
var results = new List<FileSearchResult>();
foreach (string filePath in Directory.GetFiles(fullDir))
{
string? fileName = Path.GetFileName(filePath);
if (fileName is null)
{
continue;
}
// Apply the optional glob filter on the file name.
if (!MatchesGlob(fileName, matcher))
{
continue;
}
// Read file content.
#if NET8_0_OR_GREATER
string fileContent = await File.ReadAllTextAsync(filePath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
#else
string fileContent;
using (var reader = new StreamReader(filePath, Encoding.UTF8))
{
fileContent = await reader.ReadToEndAsync().ConfigureAwait(false);
}
#endif
// Search each line for regex matches, tracking line numbers and building a snippet.
string[] lines = fileContent.Split('\n');
var matchingLines = new List<FileSearchMatch>();
string? firstSnippet = null;
int lineStartOffset = 0;
for (int i = 0; i < lines.Length; i++)
{
Match match = regex.Match(lines[i]);
if (match.Success)
{
matchingLines.Add(new FileSearchMatch { LineNumber = i + 1, Line = lines[i].TrimEnd('\r') });
// Build a context snippet around the first match (±50 chars).
if (firstSnippet is null)
{
int charIndex = lineStartOffset + match.Index;
int snippetStart = Math.Max(0, charIndex - 50);
int snippetEnd = Math.Min(fileContent.Length, charIndex + match.Value.Length + 50);
firstSnippet = fileContent.Substring(snippetStart, snippetEnd - snippetStart);
}
}
// Advance the offset past this line (including the '\n' separator).
lineStartOffset += lines[i].Length + 1;
}
if (matchingLines.Count > 0)
{
results.Add(new FileSearchResult
{
FileName = fileName,
Snippet = firstSnippet!,
MatchingLines = matchingLines,
});
}
}
return results;
}
/// <inheritdoc />
public override Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default)
{
string fullPath = this.ResolveSafeDirectoryPath(path);
Directory.CreateDirectory(fullPath);
return Task.CompletedTask;
}
/// <summary>
/// Resolves a relative file path to a safe absolute path under the root directory.
/// Rejects paths that would escape the root via traversal or rooted paths.
/// </summary>
private string ResolveSafePath(string relativePath)
{
ValidateRelativePath(relativePath);
// Normalize separators before combining to prevent backslashes from becoming
// literal filename characters on Unix.
string normalized = relativePath.Replace('\\', '/').Replace('/', Path.DirectorySeparatorChar);
string combined = Path.Combine(this._rootPath, normalized);
string fullPath = Path.GetFullPath(combined);
if (!fullPath.StartsWith(this._rootPath, StringComparison.Ordinal))
{
throw new ArgumentException(
$"Invalid path: '{relativePath}'. The resolved path escapes the root directory.",
nameof(relativePath));
}
return fullPath;
}
/// <summary>
/// Resolves a relative directory path to a safe absolute path under the root directory.
/// An empty string resolves to the root directory itself.
/// </summary>
private string ResolveSafeDirectoryPath(string relativeDirectory)
{
if (string.IsNullOrEmpty(relativeDirectory))
{
return this._rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
return this.ResolveSafePath(relativeDirectory);
}
/// <summary>
/// Validates that a relative path does not contain rooted paths or traversal segments.
/// </summary>
private static void ValidateRelativePath(string path)
{
string normalized = path.Replace('\\', '/');
if (Path.IsPathRooted(path) ||
path.StartsWith("/", StringComparison.Ordinal) ||
path.StartsWith("\\", StringComparison.Ordinal) ||
(normalized.Length >= 2 && char.IsLetter(normalized[0]) && normalized[1] == ':'))
{
throw new ArgumentException(
$"Invalid path: '{path}'. Paths must be relative and must not start with '/', '\\', or a drive root.",
nameof(path));
}
foreach (string segment in normalized.Split('/'))
{
if (segment.Equals(".", StringComparison.Ordinal) || segment.Equals("..", StringComparison.Ordinal))
{
throw new ArgumentException(
$"Invalid path: '{path}'. Paths must not contain '.' or '..' segments.",
nameof(path));
}
}
if (normalized.StartsWith("/", StringComparison.Ordinal) ||
normalized.EndsWith("/", StringComparison.Ordinal))
{
throw new ArgumentException(
$"Invalid path: '{path}'. Paths must not start or end with a directory separator.",
nameof(path));
}
}
}
@@ -0,0 +1,333 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory;
public sealed class FileSystemAgentFileStoreTests : IDisposable
{
private readonly string _rootDir;
private readonly FileSystemAgentFileStore _store;
public FileSystemAgentFileStoreTests()
{
this._rootDir = Path.Combine(Path.GetTempPath(), "FileSystemAgentFileStoreTests_" + Guid.NewGuid().ToString("N"));
this._store = new FileSystemAgentFileStore(this._rootDir);
}
public void Dispose()
{
if (Directory.Exists(this._rootDir))
{
Directory.Delete(this._rootDir, recursive: true);
}
}
#region Constructor
[Fact]
public void Constructor_CreatesRootDirectory()
{
// Assert
Assert.True(Directory.Exists(this._rootDir));
}
[Fact]
public void Constructor_NullRootDirectory_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new FileSystemAgentFileStore(null!));
}
[Fact]
public void Constructor_EmptyRootDirectory_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => new FileSystemAgentFileStore(""));
}
[Fact]
public void Constructor_WhitespaceRootDirectory_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => new FileSystemAgentFileStore(" "));
}
#endregion
#region Path Traversal Rejection
[Fact]
public async Task WriteFileAsync_DotDotSegment_ThrowsAsync()
{
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => this._store.WriteFileAsync("../escape.txt", "content"));
}
[Fact]
public async Task ReadFileAsync_AbsolutePath_ThrowsAsync()
{
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => this._store.ReadFileAsync("/etc/passwd"));
}
[Fact]
public async Task DeleteFileAsync_DriveRootedPath_ThrowsAsync()
{
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => this._store.DeleteFileAsync("C:\\temp\\file.txt"));
}
[Fact]
public async Task WriteFileAsync_DotSegment_ThrowsAsync()
{
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => this._store.WriteFileAsync("./file.txt", "content"));
}
[Fact]
public async Task WriteFileAsync_DoubleDotsInFileName_AllowedAsync()
{
// Arrange — "notes..md" contains ".." but is not a ".." segment
await this._store.WriteFileAsync("notes..md", "content");
// Act
string? result = await this._store.ReadFileAsync("notes..md");
// Assert
Assert.Equal("content", result);
}
[Fact]
public async Task WriteFileAsync_TrailingSlash_ThrowsAsync()
{
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => this._store.WriteFileAsync("subdir/", "content"));
}
#endregion
#region Write and Read
[Fact]
public async Task WriteAndReadAsync_RoundTripsAsync()
{
// Arrange
await this._store.WriteFileAsync("test.txt", "hello world");
// Act
string? content = await this._store.ReadFileAsync("test.txt");
// Assert
Assert.Equal("hello world", content);
}
[Fact]
public async Task WriteFileAsync_OverwritesExistingAsync()
{
// Arrange
await this._store.WriteFileAsync("test.txt", "first");
await this._store.WriteFileAsync("test.txt", "second");
// Act
string? content = await this._store.ReadFileAsync("test.txt");
// Assert
Assert.Equal("second", content);
}
[Fact]
public async Task ReadFileAsync_NonExistent_ReturnsNullAsync()
{
// Act
string? content = await this._store.ReadFileAsync("missing.txt");
// Assert
Assert.Null(content);
}
#endregion
#region Delete
[Fact]
public async Task DeleteFileAsync_ExistingFile_ReturnsTrueAsync()
{
// Arrange
await this._store.WriteFileAsync("delete-me.txt", "content");
// Act
bool deleted = await this._store.DeleteFileAsync("delete-me.txt");
// Assert
Assert.True(deleted);
Assert.Null(await this._store.ReadFileAsync("delete-me.txt"));
}
[Fact]
public async Task DeleteFileAsync_NonExistent_ReturnsFalseAsync()
{
// Act
bool deleted = await this._store.DeleteFileAsync("nope.txt");
// Assert
Assert.False(deleted);
}
#endregion
#region FileExists
[Fact]
public async Task FileExistsAsync_ExistingFile_ReturnsTrueAsync()
{
// Arrange
await this._store.WriteFileAsync("exists.txt", "content");
// Act & Assert
Assert.True(await this._store.FileExistsAsync("exists.txt"));
}
[Fact]
public async Task FileExistsAsync_NonExistent_ReturnsFalseAsync()
{
// Act & Assert
Assert.False(await this._store.FileExistsAsync("missing.txt"));
}
#endregion
#region ListFiles
[Fact]
public async Task ListFilesAsync_ReturnsDirectChildrenOnlyAsync()
{
// Arrange
await this._store.WriteFileAsync("root.txt", "content");
await this._store.WriteFileAsync("sub/nested.txt", "content");
// Act
var files = await this._store.ListFilesAsync("");
// Assert
Assert.Single(files);
Assert.Equal("root.txt", files[0]);
}
[Fact]
public async Task ListFilesAsync_SubDirectory_ReturnsChildrenAsync()
{
// Arrange
await this._store.WriteFileAsync("sub/a.txt", "content");
await this._store.WriteFileAsync("sub/b.txt", "content");
await this._store.WriteFileAsync("other.txt", "content");
// Act
var files = await this._store.ListFilesAsync("sub");
// Assert
Assert.Equal(2, files.Count);
Assert.Contains("a.txt", files);
Assert.Contains("b.txt", files);
}
[Fact]
public async Task ListFilesAsync_NonExistentDirectory_ReturnsEmptyAsync()
{
// Act
var files = await this._store.ListFilesAsync("no-such-dir");
// Assert
Assert.Empty(files);
}
#endregion
#region CreateDirectory
[Fact]
public async Task CreateDirectoryAsync_CreatesOnDiskAsync()
{
// Act
await this._store.CreateDirectoryAsync("new-dir");
// Assert
Assert.True(Directory.Exists(Path.Combine(this._rootDir, "new-dir")));
}
#endregion
#region SearchFiles
[Fact]
public async Task SearchFilesAsync_FindsMatchAsync()
{
// Arrange
await this._store.WriteFileAsync("doc.md", "This has an error on line one.\nLine two is fine.");
// Act
var results = await this._store.SearchFilesAsync("", "error");
// Assert
Assert.Single(results);
Assert.Equal("doc.md", results[0].FileName);
Assert.Single(results[0].MatchingLines);
Assert.Equal(1, results[0].MatchingLines[0].LineNumber);
Assert.Contains("error", results[0].Snippet);
}
[Fact]
public async Task SearchFilesAsync_GlobFilter_ExcludesNonMatchingAsync()
{
// Arrange
await this._store.WriteFileAsync("notes.md", "important info");
await this._store.WriteFileAsync("data.txt", "important info");
// Act
var results = await this._store.SearchFilesAsync("", "important", "*.md");
// Assert
Assert.Single(results);
Assert.Equal("notes.md", results[0].FileName);
}
[Fact]
public async Task SearchFilesAsync_NoMatch_ReturnsEmptyAsync()
{
// Arrange
await this._store.WriteFileAsync("doc.md", "nothing here");
// Act
var results = await this._store.SearchFilesAsync("", "missing-pattern");
// Assert
Assert.Empty(results);
}
[Fact]
public async Task SearchFilesAsync_NonExistentDirectory_ReturnsEmptyAsync()
{
// Act
var results = await this._store.SearchFilesAsync("no-dir", "anything");
// Assert
Assert.Empty(results);
}
[Fact]
public async Task SearchFilesAsync_RegexTimeout_ThrowsOnBadPatternAsync()
{
// Arrange — write a file with content that triggers catastrophic backtracking.
// The pattern (a+)+$ with a string of 'a's followed by 'b' forces exponential backtracking.
await this._store.WriteFileAsync("trap.txt", new string('a', 30) + "b");
// Act & Assert — a known ReDoS pattern with backtracking
await Assert.ThrowsAsync<RegexMatchTimeoutException>(() =>
this._store.SearchFilesAsync("", "(a+)+$"));
}
#endregion
}