Skip to content
Open
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 @@ -327,7 +327,7 @@ private async Task<string> DeleteAsync(string fileName, CancellationToken cancel
/// <param name="globPattern">An optional glob pattern (e.g., "*.md") matched against entry names to filter the listing.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of entries, each with a name and a type of "file" or "directory" (subdirectories first).</returns>
[Description("List the direct child files and subdirectories of a directory. Omit the directory (or pass an empty string) to list the root. To enumerate a subdirectory, pass its relative path, for example \"reports\" or \"reports/2024\". Optionally filter entries with a glob_pattern (e.g. \"*.md\"). Subdirectories are listed before files, and each entry has a name and a type of \"file\" or \"directory\".")]
[Description("List the direct child files and subdirectories of a directory. Omit the directory (or pass an empty string) to list the root. To enumerate a subdirectory, pass its relative path, for example \"reports\" or \"reports/2024\". Optionally filter entries with a globPattern (e.g. \"*.md\"). Subdirectories are listed before files, and each entry has a name and a type of \"file\" or \"directory\".")]
private async Task<List<FileStoreEntry>> LsAsync(string? directory = null, string? globPattern = null, CancellationToken cancellationToken = default)
{
string target = string.IsNullOrWhiteSpace(directory) ? string.Empty : directory!;
Expand All @@ -346,7 +346,7 @@ private async Task<List<FileStoreEntry>> LsAsync(string? directory = null, strin
/// <param name="replaceAll">When <see langword="true"/>, replace every occurrence; otherwise fail unless exactly one occurrence exists.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation message including the number of occurrences replaced, or a failure message.</returns>
[Description("Replace occurrences of old_string with new_string in a file. Fails if old_string is not found, or if it occurs more than once and replace_all is false. Returns the number of occurrences replaced.")]
[Description("Replace occurrences of oldString with newString in a file. Fails if oldString is not found, or if it occurs more than once and replaceAll is false. Returns the number of occurrences replaced.")]
private async Task<string> ReplaceAsync(string fileName, string oldString, string newString, bool replaceAll = false, CancellationToken cancellationToken = default)
{
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ private async Task<string> DeleteAsync(string fileName, CancellationToken cancel
/// <param name="globPattern">An optional glob pattern (e.g., "*.md") matched against file names to filter the listing.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of file entries with names and optional descriptions.</returns>
[Description("List all memory files with their descriptions (if available). Optionally filter file names with a glob_pattern (e.g. \"*.md\"). Internal files (description sidecars and the memory index) are not shown.")]
[Description("List all memory files with their descriptions (if available). Optionally filter file names with a globPattern (e.g. \"*.md\"). Internal files (description sidecars and the memory index) are not shown.")]
private async Task<List<FileListEntry>> LsAsync(string? globPattern = null, CancellationToken cancellationToken = default)
{
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
Expand Down Expand Up @@ -319,7 +319,7 @@ private async Task<List<FileListEntry>> LsAsync(string? globPattern = null, Canc
/// <param name="replaceAll">When <see langword="true"/>, replace every occurrence; otherwise fail unless exactly one occurrence exists.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A confirmation message including the number of occurrences replaced, or a failure message.</returns>
[Description("Replace occurrences of old_string with new_string in a memory file. Fails if old_string is not found, or if it occurs more than once and replace_all is false. Returns the number of occurrences replaced.")]
[Description("Replace occurrences of oldString with newString in a memory file. Fails if oldString is not found, or if it occurs more than once and replaceAll is false. Returns the number of occurrences replaced.")]
private async Task<string> ReplaceAsync(string fileName, string oldString, string newString, bool replaceAll = false, CancellationToken cancellationToken = default)
{
string normalized = StorePaths.NormalizeRelativePath(fileName);
Expand Down Expand Up @@ -394,7 +394,7 @@ private async Task<string> ReplaceLinesAsync(string fileName, List<FileLineEdit>
/// <param name="globPattern">An optional glob pattern to filter which files to search (e.g., "*.md", "research*"). Leave empty or omit to search all files.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of search results with matching file names, snippets, and matching lines.</returns>
[Description("Search memory file contents using a regular expression pattern (case-insensitive). Optionally filter which files to search using a glob_pattern (e.g., \"*.md\", \"research*\"). Returns matching file names, content snippets, and matching lines with line numbers.")]
[Description("Search memory file contents using a regular expression pattern (case-insensitive). Optionally filter which files to search using a globPattern (e.g., \"*.md\", \"research*\"). Returns matching file names, content snippets, and matching lines with line numbers.")]
private async Task<List<FileSearchResult>> GrepAsync(string regexPattern, string? globPattern = null, CancellationToken cancellationToken = default)
{
FileMemoryState state = this._sessionState.GetOrInitializeState(AIAgent.CurrentRunContext?.Session);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Copyright (c) Microsoft. All rights reserved.

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.FileAccess;

/// <summary>
/// Verifies that each file access tool's description refers to arguments by the same
/// names that <see cref="AIFunctionFactory"/> exposes in the generated JSON schema.
/// </summary>
public class FileAccessToolDescriptionSchemaTests
{
[Fact]
public async Task LsTool_Description_UsesSchemaArgumentNameAsync()
{
// Arrange
AIFunction tool = await GetToolAsync(FileAccessProvider.LsToolName);

// Act
List<string> schemaArguments = GetSchemaArgumentNames(tool);

// Assert — the description and the generated schema agree on the filter argument name.
Assert.Contains("globPattern", tool.Description);
Assert.DoesNotContain("glob_pattern", tool.Description);
Assert.Contains("globPattern", schemaArguments);
}

[Fact]
public async Task ReplaceTool_Description_UsesSchemaArgumentNamesAsync()
{
// Arrange
AIFunction tool = await GetToolAsync(FileAccessProvider.ReplaceToolName);

// Act
List<string> schemaArguments = GetSchemaArgumentNames(tool);

// Assert — the description and the generated schema agree on the replace argument names.
Assert.Contains("oldString", tool.Description);
Assert.Contains("newString", tool.Description);
Assert.Contains("replaceAll", tool.Description);
Assert.DoesNotContain("old_string", tool.Description);
Assert.DoesNotContain("new_string", tool.Description);
Assert.DoesNotContain("replace_all", tool.Description);
Assert.Contains("oldString", schemaArguments);
Assert.Contains("newString", schemaArguments);
Assert.Contains("replaceAll", schemaArguments);
}

[Fact]
public async Task ReplaceLinesTool_Description_KeepsExplicitJsonPropertyNamesAsync()
{
// Arrange
AIFunction tool = await GetToolAsync(FileAccessProvider.ReplaceLinesToolName);

// Act
List<string> schemaArguments = GetSchemaArgumentNames(tool);

// Assert — line_number/new_line are mapped explicitly via JsonPropertyName, so the
// description must keep referencing them as-is instead of a camelCase rename.
Assert.Contains("line_number", tool.Description);
Assert.Contains("new_line", tool.Description);
Assert.Contains("line_number", schemaArguments);
Assert.Contains("new_line", schemaArguments);
}

private static async Task<AIFunction> GetToolAsync(string toolName)
{
// Arrange
var provider = new FileAccessProvider(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

AIContext result = await provider.InvokingAsync(context);
return (AIFunction)result.Tools!.First(t => t is AIFunction f && f.Name == toolName);
}

private static List<string> GetSchemaArgumentNames(AIFunction tool)
{
var names = new List<string>();
if (tool.JsonSchema is JsonElement schema)
{
CollectPropertyNames(schema, names);
}

return names.Distinct().ToList();
}

private static void CollectPropertyNames(JsonElement element, List<string> names)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty property in element.EnumerateObject())
{
if (property.Name == "properties")
{
foreach (JsonProperty item in property.Value.EnumerateObject())
{
names.Add(item.Name);
CollectPropertyNames(item.Value, names);
}
}
else
{
CollectPropertyNames(property.Value, names);
}
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in element.EnumerateArray())
{
CollectPropertyNames(item, names);
}
}
Comment on lines +87 to +122
}
Comment on lines +71 to +123
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) Microsoft. All rights reserved.

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;

/// <summary>
/// Verifies that each file memory tool's description refers to arguments by the same
/// names that <see cref="AIFunctionFactory"/> exposes in the generated JSON schema.
/// </summary>
public class FileMemoryToolDescriptionSchemaTests
{
[Theory]
[InlineData(FileMemoryProvider.LsToolName)]
[InlineData(FileMemoryProvider.GrepToolName)]
public async Task FilteringTool_Description_UsesSchemaArgumentNameAsync(string toolName)
{
// Arrange
AIFunction tool = await GetToolAsync(toolName);

// Act
List<string> schemaArguments = GetSchemaArgumentNames(tool);

// Assert — the description and the generated schema agree on the filter argument name.
Assert.Contains("globPattern", tool.Description);
Assert.DoesNotContain("glob_pattern", tool.Description);
Assert.Contains("globPattern", schemaArguments);
}

[Fact]
public async Task ReplaceTool_Description_UsesSchemaArgumentNamesAsync()
{
// Arrange
AIFunction tool = await GetToolAsync(FileMemoryProvider.ReplaceToolName);

// Act
List<string> schemaArguments = GetSchemaArgumentNames(tool);

// Assert — the description and the generated schema agree on the replace argument names.
Assert.Contains("oldString", tool.Description);
Assert.Contains("newString", tool.Description);
Assert.Contains("replaceAll", tool.Description);
Assert.DoesNotContain("old_string", tool.Description);
Assert.DoesNotContain("new_string", tool.Description);
Assert.DoesNotContain("replace_all", tool.Description);
Assert.Contains("oldString", schemaArguments);
Assert.Contains("newString", schemaArguments);
Assert.Contains("replaceAll", schemaArguments);
}

[Fact]
public async Task ReplaceLinesTool_Description_KeepsExplicitJsonPropertyNamesAsync()
{
// Arrange
AIFunction tool = await GetToolAsync(FileMemoryProvider.ReplaceLinesToolName);

// Act
List<string> schemaArguments = GetSchemaArgumentNames(tool);

// Assert — line_number/new_line are mapped explicitly via JsonPropertyName, so the
// description must keep referencing them as-is instead of a camelCase rename.
Assert.Contains("line_number", tool.Description);
Assert.Contains("new_line", tool.Description);
Assert.Contains("line_number", schemaArguments);
Assert.Contains("new_line", schemaArguments);
}

private static async Task<AIFunction> GetToolAsync(string toolName)
{
// 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

AIContext result = await provider.InvokingAsync(context);
return (AIFunction)result.Tools!.First(t => t is AIFunction f && f.Name == toolName);
}

private static List<string> GetSchemaArgumentNames(AIFunction tool)
{
var names = new List<string>();
if (tool.JsonSchema is JsonElement schema)
{
CollectPropertyNames(schema, names);
}

return names.Distinct().ToList();
}

private static void CollectPropertyNames(JsonElement element, List<string> names)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty property in element.EnumerateObject())
{
if (property.Name == "properties")
{
foreach (JsonProperty item in property.Value.EnumerateObject())
{
names.Add(item.Name);
CollectPropertyNames(item.Value, names);
}
}
else
{
CollectPropertyNames(property.Value, names);
}
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in element.EnumerateArray())
{
CollectPropertyNames(item, names);
}
}
}
}
Loading