db620d33df
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
1559 lines
66 KiB
C#
1559 lines
66 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.AI;
|
|
|
|
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
|
|
|
/// <summary>
|
|
/// Unit tests for the <see cref="AgentSkillsProvider"/> class with <see cref="AgentFileSkillsSource"/>.
|
|
/// </summary>
|
|
public sealed class AgentSkillsProviderTests : IDisposable
|
|
{
|
|
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, sp, ct) => Task.FromResult<object?>(null);
|
|
private readonly string _testRoot;
|
|
private readonly TestAIAgent _agent = new();
|
|
|
|
public AgentSkillsProviderTests()
|
|
{
|
|
this._testRoot = Path.Combine(Path.GetTempPath(), "skills-provider-tests-" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(this._testRoot);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(this._testRoot))
|
|
{
|
|
Directory.Delete(this._testRoot, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_NoSkills_ReturnsInputContextUnchangedAsync()
|
|
{
|
|
// Arrange
|
|
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
|
var inputContext = new AIContext { Instructions = "Original instructions" };
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.Equal("Original instructions", result.Instructions);
|
|
Assert.Null(result.Tools);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync()
|
|
{
|
|
// Arrange
|
|
this.CreateSkill("provider-skill", "Provider skill test", "Skill instructions body.");
|
|
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
|
var inputContext = new AIContext { Instructions = "Base instructions" };
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("Base instructions", result.Instructions);
|
|
Assert.Contains("provider-skill", result.Instructions);
|
|
Assert.Contains("Provider skill test", result.Instructions);
|
|
|
|
// Should have load_skill, read_skill_resource, and run_skill_script tools
|
|
Assert.NotNull(result.Tools);
|
|
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
|
Assert.Contains("load_skill", toolNames);
|
|
Assert.Contains("read_skill_resource", toolNames);
|
|
Assert.Contains("run_skill_script", toolNames);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync()
|
|
{
|
|
// Arrange
|
|
this.CreateSkill("null-instr-skill", "Null instruction test", "Body.");
|
|
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
|
var inputContext = new AIContext();
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("null-instr-skill", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync()
|
|
{
|
|
// Arrange
|
|
this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body.");
|
|
var options = new AgentSkillsProviderOptions
|
|
{
|
|
SkillsInstructionPrompt = "Custom template: {skills}"
|
|
};
|
|
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options);
|
|
var inputContext = new AIContext();
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.StartsWith("Custom template:", result.Instructions);
|
|
Assert.Contains("custom-prompt-skill", result.Instructions);
|
|
Assert.Contains("Custom prompt", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_PromptWithoutSkillsPlaceholder_ThrowsArgumentException()
|
|
{
|
|
// Arrange
|
|
var options = new AgentSkillsProviderOptions
|
|
{
|
|
SkillsInstructionPrompt = "No skills placeholder here"
|
|
};
|
|
|
|
// Act & Assert
|
|
var ex = Assert.Throws<ArgumentException>(() =>
|
|
new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options));
|
|
Assert.Contains("{skills}", ex.Message);
|
|
Assert.Equal("options", ex.ParamName);
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_PromptWithOnlySkillsPlaceholder_Succeeds()
|
|
{
|
|
// Arrange
|
|
var options = new AgentSkillsProviderOptions
|
|
{
|
|
SkillsInstructionPrompt = "Skills: {skills}"
|
|
};
|
|
|
|
// Act — should not throw
|
|
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options);
|
|
|
|
// Assert
|
|
Assert.NotNull(provider);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_CustomTemplateWithLegacyPlaceholders_RendersThemLiterallyAsync()
|
|
{
|
|
// Arrange — template contains legacy placeholder tokens that are no longer substituted
|
|
this.CreateSkill("literal-test-skill", "Literal test", "Body.");
|
|
var options = new AgentSkillsProviderOptions
|
|
{
|
|
SkillsInstructionPrompt = "Skills: {skills}\nRes: {resource_instructions}\nScript: {script_instructions}"
|
|
};
|
|
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options);
|
|
var inputContext = new AIContext();
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — legacy tokens render literally, not substituted
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("{resource_instructions}", result.Instructions);
|
|
Assert.Contains("{script_instructions}", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync()
|
|
{
|
|
// Arrange — description with XML-sensitive characters
|
|
string skillDir = Path.Combine(this._testRoot, "xml-skill");
|
|
Directory.CreateDirectory(skillDir);
|
|
File.WriteAllText(
|
|
Path.Combine(skillDir, "SKILL.md"),
|
|
"---\nname: xml-skill\ndescription: Uses <tags> & \"quotes\"\n---\nBody.");
|
|
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
|
var inputContext = new AIContext();
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("<tags>", result.Instructions);
|
|
Assert.Contains("&", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Constructor_WithMultiplePaths_LoadsFromAllAsync()
|
|
{
|
|
// Arrange
|
|
string dir1 = Path.Combine(this._testRoot, "dir1");
|
|
string dir2 = Path.Combine(this._testRoot, "dir2");
|
|
CreateSkillIn(dir1, "skill-a", "Skill A", "Body A.");
|
|
CreateSkillIn(dir2, "skill-b", "Skill B", "Body B.");
|
|
|
|
// Act
|
|
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(new[] { dir1, dir2 }, s_noOpExecutor));
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Assert
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("skill-a", result.Instructions);
|
|
Assert.Contains("skill-b", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync()
|
|
{
|
|
// Arrange
|
|
this.CreateSkill("tools-skill", "Tools test", "Body.");
|
|
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
|
|
|
var existingTool = AIFunctionFactory.Create(() => "test", name: "existing_tool", description: "An existing tool.");
|
|
var inputContext = new AIContext { Tools = new[] { existingTool } };
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — existing tool should be preserved alongside the new skill tools
|
|
Assert.NotNull(result.Tools);
|
|
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
|
Assert.Contains("existing_tool", toolNames);
|
|
Assert.Contains("load_skill", toolNames);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_SkillsListIsSortedByNameAsync()
|
|
{
|
|
// Arrange — create skills in reverse alphabetical order
|
|
this.CreateSkill("zulu-skill", "Zulu skill", "Body Z.");
|
|
this.CreateSkill("alpha-skill", "Alpha skill", "Body A.");
|
|
this.CreateSkill("mike-skill", "Mike skill", "Body M.");
|
|
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
|
var inputContext = new AIContext();
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — skills should appear in alphabetical order in the prompt
|
|
Assert.NotNull(result.Instructions);
|
|
int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal);
|
|
int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal);
|
|
int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal);
|
|
Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill");
|
|
Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProvideAIContextAsync_ConcurrentCalls_LoadsSkillsOnlyOnceAsync()
|
|
{
|
|
// Arrange
|
|
var source = new CountingAgentSkillsSource(
|
|
[
|
|
new AgentInlineSkill("concurrent-skill", "Concurrent test", "Body.")
|
|
]);
|
|
var provider = new AgentSkillsProvider(new CachingAgentSkillsSource(source));
|
|
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act — invoke concurrently from multiple threads
|
|
var tasks = Enumerable.Range(0, 10)
|
|
.Select(_ => provider.InvokingAsync(invokingContext, CancellationToken.None).AsTask())
|
|
.ToArray();
|
|
await Task.WhenAll(tasks);
|
|
|
|
// Assert — GetSkillsAsync should have been called exactly once (source-level caching)
|
|
Assert.Equal(1, source.GetSkillsCallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_WithScripts_IncludesRunSkillScriptToolAsync()
|
|
{
|
|
// Arrange
|
|
string skillDir = Path.Combine(this._testRoot, "script-skill");
|
|
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
|
File.WriteAllText(
|
|
Path.Combine(skillDir, "SKILL.md"),
|
|
"---\nname: script-skill\ndescription: Skill with scripts\n---\nBody.");
|
|
File.WriteAllText(
|
|
Path.Combine(skillDir, "scripts", "test.py"),
|
|
"print('hello')");
|
|
|
|
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
|
var provider = new AgentSkillsProvider(source);
|
|
var inputContext = new AIContext();
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Tools);
|
|
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
|
Assert.Contains("run_skill_script", toolNames);
|
|
Assert.Contains("load_skill", toolNames);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_WithoutScripts_StillIncludesAllToolsAsync()
|
|
{
|
|
// Arrange
|
|
this.CreateSkill("no-script-skill", "No scripts", "Body.");
|
|
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
|
var provider = new AgentSkillsProvider(source);
|
|
var inputContext = new AIContext();
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — all tools are always included regardless of skill content
|
|
Assert.NotNull(result.Tools);
|
|
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
|
Assert.Contains("load_skill", toolNames);
|
|
Assert.Contains("read_skill_resource", toolNames);
|
|
Assert.Contains("run_skill_script", toolNames);
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_WithFileSkillsButNoExecutor_ThrowsInvalidOperationException()
|
|
{
|
|
// Arrange
|
|
var builder = new AgentSkillsProviderBuilder()
|
|
.UseFileSkill(this._testRoot);
|
|
|
|
// Act & Assert
|
|
Assert.Throws<InvalidOperationException>(() => builder.Build());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Builder_UseFileSkillWithOptions_DiscoverSkillsAsync()
|
|
{
|
|
// Arrange
|
|
this.CreateSkill("opts-skill", "Options skill", "Options body.");
|
|
var options = new AgentFileSkillsSourceOptions();
|
|
var provider = new AgentSkillsProviderBuilder()
|
|
.UseFileSkill(this._testRoot, options)
|
|
.UseFileScriptRunner(s_noOpExecutor)
|
|
.Build();
|
|
|
|
// Act
|
|
var inputContext = new AIContext();
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("opts-skill", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Builder_UseFileSkillsWithOptions_DiscoverMultipleSkillsAsync()
|
|
{
|
|
// Arrange
|
|
string dir1 = Path.Combine(this._testRoot, "multi-opts-1");
|
|
string dir2 = Path.Combine(this._testRoot, "multi-opts-2");
|
|
CreateSkillIn(dir1, "skill-x", "Skill X", "Body X.");
|
|
CreateSkillIn(dir2, "skill-y", "Skill Y", "Body Y.");
|
|
|
|
var options = new AgentFileSkillsSourceOptions();
|
|
var provider = new AgentSkillsProviderBuilder()
|
|
.UseFileSkills(new[] { dir1, dir2 }, options)
|
|
.UseFileScriptRunner(s_noOpExecutor)
|
|
.Build();
|
|
|
|
// Act
|
|
var inputContext = new AIContext();
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("skill-x", result.Instructions);
|
|
Assert.Contains("skill-y", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Builder_UseFileSkillWithOptionsResourceFilter_FiltersResourcesAsync()
|
|
{
|
|
// Arrange — create a skill with both .md and .json resources
|
|
string skillDir = Path.Combine(this._testRoot, "res-filter-opts");
|
|
CreateSkillIn(skillDir, "filter-skill", "Filter test", "Filter body.");
|
|
File.WriteAllText(Path.Combine(skillDir, "data.json"), "{}", System.Text.Encoding.UTF8);
|
|
File.WriteAllText(Path.Combine(skillDir, "notes.txt"), "notes", System.Text.Encoding.UTF8);
|
|
|
|
// Only allow .json resources
|
|
var options = new AgentFileSkillsSourceOptions
|
|
{
|
|
AllowedResourceExtensions = [".json"],
|
|
};
|
|
var source = new AgentFileSkillsSource(skillDir, s_noOpExecutor, options);
|
|
|
|
// Act
|
|
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create(this._agent));
|
|
|
|
// Assert
|
|
Assert.Single(skills);
|
|
var fileSkill = Assert.IsType<AgentFileSkill>(skills[0]);
|
|
Assert.All(fileSkill.GetTestResources()!, r => Assert.EndsWith(".json", r.Name));
|
|
}
|
|
|
|
private void CreateSkill(string name, string description, string body)
|
|
{
|
|
CreateSkillIn(this._testRoot, name, description, body);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadSkill_DefaultOptions_ReturnsFullContentAsync()
|
|
{
|
|
// Arrange
|
|
this.CreateSkill("content-skill", "Content test", "Skill body.");
|
|
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
|
var inputContext = new AIContext();
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
|
|
|
|
// Act
|
|
var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?> { ["skillName"] = "content-skill" }));
|
|
|
|
// Assert — should contain frontmatter and body
|
|
var text = content!.ToString()!;
|
|
Assert.Contains("---", text);
|
|
Assert.Contains("name: content-skill", text);
|
|
Assert.Contains("Skill body.", text);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadSkill_EmptySkillName_ReturnsErrorAsync()
|
|
{
|
|
// Arrange
|
|
this.CreateSkill("any-skill", "Test", "Body.");
|
|
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
|
|
|
|
// Act
|
|
var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?> { ["skillName"] = "" }));
|
|
|
|
// Assert
|
|
Assert.Equal("Error: Skill name cannot be empty.", content!.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadSkill_SkillNotFound_ReturnsErrorAsync()
|
|
{
|
|
// Arrange
|
|
this.CreateSkill("only-skill", "Test", "Body.");
|
|
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
|
|
|
|
// Act
|
|
var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?> { ["skillName"] = "non-existent" }));
|
|
|
|
// Assert
|
|
Assert.Equal("Error: Skill 'non-existent' not found.", content!.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_WithResources_IncludesReadSkillResourceToolAsync()
|
|
{
|
|
// Arrange — inline skill with a resource
|
|
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
|
skill.AddResource("config", "value1", "A config resource.");
|
|
var provider = new AgentSkillsProvider(skill);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Tools);
|
|
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
|
Assert.Contains("read_skill_resource", toolNames);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadSkillResource_ReturnsResourceContentAsync()
|
|
{
|
|
// Arrange — inline skill with a resource
|
|
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
|
skill.AddResource("config", "resource-value");
|
|
var provider = new AgentSkillsProvider(skill);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
|
|
|
// Act
|
|
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
|
{
|
|
["skillName"] = "res-skill",
|
|
["resourceName"] = "config",
|
|
})
|
|
{
|
|
Services = new TestServiceProvider(),
|
|
});
|
|
|
|
// Assert
|
|
Assert.Equal("resource-value", content!.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadSkillResource_EmptySkillName_ReturnsErrorAsync()
|
|
{
|
|
// Arrange
|
|
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
|
skill.AddResource("config", "v");
|
|
var provider = new AgentSkillsProvider(skill);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
|
|
|
// Act
|
|
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
|
{
|
|
["skillName"] = "",
|
|
["resourceName"] = "config",
|
|
})
|
|
{
|
|
Services = new TestServiceProvider(),
|
|
});
|
|
|
|
// Assert
|
|
Assert.Equal("Error: Skill name cannot be empty.", content!.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadSkillResource_EmptyResourceName_ReturnsErrorAsync()
|
|
{
|
|
// Arrange
|
|
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
|
skill.AddResource("config", "v");
|
|
var provider = new AgentSkillsProvider(skill);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
|
|
|
// Act
|
|
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
|
{
|
|
["skillName"] = "res-skill",
|
|
["resourceName"] = "",
|
|
})
|
|
{
|
|
Services = new TestServiceProvider(),
|
|
});
|
|
|
|
// Assert
|
|
Assert.Equal("Error: Resource name cannot be empty.", content!.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadSkillResource_SkillNotFound_ReturnsErrorAsync()
|
|
{
|
|
// Arrange
|
|
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
|
skill.AddResource("config", "v");
|
|
var provider = new AgentSkillsProvider(skill);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
|
|
|
// Act
|
|
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
|
{
|
|
["skillName"] = "non-existent",
|
|
["resourceName"] = "config",
|
|
})
|
|
{
|
|
Services = new TestServiceProvider(),
|
|
});
|
|
|
|
// Assert
|
|
Assert.Equal("Error: Skill 'non-existent' not found.", content!.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadSkillResource_ResourceNotFound_ReturnsErrorAsync()
|
|
{
|
|
// Arrange
|
|
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
|
skill.AddResource("config", "v");
|
|
var provider = new AgentSkillsProvider(skill);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
|
|
|
// Act
|
|
var content = await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
|
{
|
|
["skillName"] = "res-skill",
|
|
["resourceName"] = "missing",
|
|
})
|
|
{
|
|
Services = new TestServiceProvider(),
|
|
});
|
|
|
|
// Assert
|
|
Assert.Equal("Error: Resource 'missing' not found in skill 'res-skill'.", content!.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadSkillResource_ResourceThrows_PropagatesExceptionAsync()
|
|
{
|
|
// Arrange
|
|
var skill = new AgentInlineSkill("res-skill", "Has resources", "Body.");
|
|
Func<object> failing = () => throw new InvalidOperationException("boom-resource");
|
|
skill.AddResource("config", failing);
|
|
var provider = new AgentSkillsProvider(skill);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var readTool = result.Tools!.First(t => t.Name == "read_skill_resource") as AIFunction;
|
|
|
|
// Act & Assert — the exception is not swallowed but propagates to the caller.
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
|
await readTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
|
{
|
|
["skillName"] = "res-skill",
|
|
["resourceName"] = "config",
|
|
})
|
|
{
|
|
Services = new TestServiceProvider(),
|
|
}));
|
|
Assert.Equal("boom-resource", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunSkillScript_EmptySkillName_ReturnsErrorAsync()
|
|
{
|
|
// Arrange
|
|
string skillDir = Path.Combine(this._testRoot, "err-script-skill");
|
|
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
|
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script-skill\ndescription: Test\n---\nBody.");
|
|
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
|
|
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
|
|
|
// Act
|
|
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
|
{
|
|
["skillName"] = "",
|
|
["scriptName"] = "scripts/run.py",
|
|
}));
|
|
|
|
// Assert
|
|
Assert.Equal("Error: Skill name cannot be empty.", content!.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunSkillScript_EmptyScriptName_ReturnsErrorAsync()
|
|
{
|
|
// Arrange
|
|
string skillDir = Path.Combine(this._testRoot, "err-script2-skill");
|
|
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
|
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script2-skill\ndescription: Test\n---\nBody.");
|
|
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
|
|
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
|
|
|
// Act
|
|
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
|
{
|
|
["skillName"] = "err-script2-skill",
|
|
["scriptName"] = "",
|
|
}));
|
|
|
|
// Assert
|
|
Assert.Equal("Error: Script name cannot be empty.", content!.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunSkillScript_SkillNotFound_ReturnsErrorAsync()
|
|
{
|
|
// Arrange
|
|
string skillDir = Path.Combine(this._testRoot, "err-script3-skill");
|
|
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
|
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script3-skill\ndescription: Test\n---\nBody.");
|
|
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
|
|
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
|
|
|
// Act
|
|
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
|
{
|
|
["skillName"] = "non-existent",
|
|
["scriptName"] = "scripts/run.py",
|
|
}));
|
|
|
|
// Assert
|
|
Assert.Equal("Error: Skill 'non-existent' not found.", content!.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunSkillScript_ScriptNotFound_ReturnsErrorAsync()
|
|
{
|
|
// Arrange
|
|
string skillDir = Path.Combine(this._testRoot, "err-script4-skill");
|
|
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
|
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "---\nname: err-script4-skill\ndescription: Test\n---\nBody.");
|
|
File.WriteAllText(Path.Combine(skillDir, "scripts", "run.py"), "print('hi')");
|
|
var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor));
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
|
|
|
// Act
|
|
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
|
{
|
|
["skillName"] = "err-script4-skill",
|
|
["scriptName"] = "scripts/missing.py",
|
|
}));
|
|
|
|
// Assert
|
|
Assert.Equal("Error: Script 'scripts/missing.py' not found in skill 'err-script4-skill'.", content!.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunSkillScript_ScriptThrows_PropagatesExceptionByDefaultAsync()
|
|
{
|
|
// Arrange
|
|
var skill = new AgentInlineSkill("script-skill", "Has scripts", "Body.");
|
|
Func<object> failing = () => throw new InvalidOperationException("boom-script");
|
|
skill.AddScript("explode", failing);
|
|
var provider = new AgentSkillsProvider(skill);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
|
|
|
// Act & Assert — exception propagates when IncludeDetailedErrors is not set
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
|
await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
|
{
|
|
["skillName"] = "script-skill",
|
|
["scriptName"] = "explode",
|
|
})
|
|
{
|
|
Services = new TestServiceProvider(),
|
|
}));
|
|
Assert.Equal("boom-script", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunSkillScript_ScriptThrows_IncludesDetailsWhenEnabledAsync()
|
|
{
|
|
// Arrange
|
|
var skill = new AgentInlineSkill("script-skill", "Has scripts", "Body.");
|
|
Func<object> failing = () => throw new InvalidOperationException("boom-script");
|
|
skill.AddScript("explode", failing);
|
|
var options = new AgentSkillsProviderOptions { IncludeDetailedErrors = true };
|
|
var provider = new AgentSkillsProvider(new[] { skill }, options);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
|
|
|
// Act
|
|
var content = await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
|
{
|
|
["skillName"] = "script-skill",
|
|
["scriptName"] = "explode",
|
|
})
|
|
{
|
|
Services = new TestServiceProvider(),
|
|
});
|
|
|
|
// Assert — includes exception message
|
|
Assert.Equal("Error: Failed to execute script 'explode' from skill 'script-skill'. Exception: boom-script", content!.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Builder_UseFileScriptRunnerAfterUseFileSkills_RunnerIsUsedAsync()
|
|
{
|
|
// Arrange — create a skill with a script file
|
|
string skillDir = Path.Combine(this._testRoot, "builder-skill");
|
|
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
|
File.WriteAllText(
|
|
Path.Combine(skillDir, "SKILL.md"),
|
|
"---\nname: builder-skill\ndescription: Builder test\n---\nBody.");
|
|
File.WriteAllText(
|
|
Path.Combine(skillDir, "scripts", "run.py"),
|
|
"print('ok')");
|
|
|
|
var executorCalled = false;
|
|
|
|
// Act — call UseFileScriptRunner AFTER UseFileSkill (the bug scenario)
|
|
var provider = new AgentSkillsProviderBuilder()
|
|
.UseFileSkill(this._testRoot)
|
|
.UseFileScriptRunner((skill, script, args, sp, ct) =>
|
|
{
|
|
executorCalled = true;
|
|
return Task.FromResult<object?>("executed");
|
|
})
|
|
.Build();
|
|
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — run_skill_script tool should be present and executor should work
|
|
Assert.NotNull(result.Tools);
|
|
var toolNames = result.Tools!.Select(t => t.Name).ToList();
|
|
Assert.Contains("run_skill_script", toolNames);
|
|
|
|
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
|
await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
|
{
|
|
["skillName"] = "builder-skill",
|
|
["scriptName"] = "scripts/run.py",
|
|
}));
|
|
|
|
Assert.True(executorCalled);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunSkillScript_ForwardsJsonArgumentsAndServiceProviderToRunnerAsync()
|
|
{
|
|
// Arrange — create a skill with a script file
|
|
string skillDir = Path.Combine(this._testRoot, "fwd-skill");
|
|
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
|
File.WriteAllText(
|
|
Path.Combine(skillDir, "SKILL.md"),
|
|
"---\nname: fwd-skill\ndescription: Forwarding test\n---\nBody.");
|
|
File.WriteAllText(
|
|
Path.Combine(skillDir, "scripts", "run.py"),
|
|
"print('ok')");
|
|
|
|
JsonElement? capturedArgs = null;
|
|
IServiceProvider? capturedServiceProvider = null;
|
|
|
|
var provider = new AgentSkillsProviderBuilder()
|
|
.UseFileSkill(this._testRoot)
|
|
.UseFileScriptRunner((skill, script, args, sp, ct) =>
|
|
{
|
|
capturedArgs = args;
|
|
capturedServiceProvider = sp;
|
|
return Task.FromResult<object?>("executed");
|
|
})
|
|
.Build();
|
|
|
|
var mockServiceProvider = new TestServiceProvider();
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
|
|
|
|
// Act — invoke with JsonElement arguments and a service provider
|
|
using var argsJsonDoc = JsonDocument.Parse("""["arg1","arg2"]""");
|
|
var argsJson = argsJsonDoc.RootElement;
|
|
await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
|
|
{
|
|
["skillName"] = "fwd-skill",
|
|
["scriptName"] = "scripts/run.py",
|
|
["arguments"] = argsJson,
|
|
})
|
|
{
|
|
Services = mockServiceProvider,
|
|
});
|
|
|
|
// Assert — JsonElement arguments and service provider are forwarded to the runner
|
|
Assert.NotNull(capturedArgs);
|
|
Assert.Equal(JsonValueKind.Array, capturedArgs!.Value.ValueKind);
|
|
Assert.Equal("""["arg1","arg2"]""", capturedArgs.Value.GetRawText());
|
|
Assert.Same(mockServiceProvider, capturedServiceProvider);
|
|
}
|
|
|
|
private sealed class TestServiceProvider : IServiceProvider
|
|
{
|
|
public object? GetService(Type serviceType) => null;
|
|
}
|
|
|
|
private static void CreateSkillIn(string root, string name, string description, string body)
|
|
{
|
|
string skillDir = Path.Combine(root, name);
|
|
Directory.CreateDirectory(skillDir);
|
|
File.WriteAllText(
|
|
Path.Combine(skillDir, "SKILL.md"),
|
|
$"---\nname: {name}\ndescription: {description}\n---\n{body}");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Build_WithCachingEnabled_CachesSkillsAsync()
|
|
{
|
|
// Arrange
|
|
var source = new CountingAgentSkillsSource(
|
|
[
|
|
new AgentInlineSkill("cached-skill", "Cached test", "Body.")
|
|
]);
|
|
var provider = new AgentSkillsProviderBuilder()
|
|
.UseSource(source)
|
|
.Build();
|
|
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — source should be called exactly once (caching is on by default)
|
|
Assert.Equal(1, source.GetSkillsCallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Build_DefaultOptions_CachesSkillsAsync()
|
|
{
|
|
// Arrange
|
|
var source = new CountingAgentSkillsSource(
|
|
[
|
|
new AgentInlineSkill("default-skill", "Default test", "Body.")
|
|
]);
|
|
var provider = new AgentSkillsProviderBuilder()
|
|
.UseSource(source)
|
|
.Build();
|
|
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — default behavior caches
|
|
Assert.Equal(1, source.GetSkillsCallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Build_PreservesSourceRegistrationOrderAsync()
|
|
{
|
|
// Arrange — register file, inline, file in that order
|
|
string dir1 = Path.Combine(this._testRoot, "dir1");
|
|
string dir2 = Path.Combine(this._testRoot, "dir2");
|
|
CreateSkillIn(dir1, "file-skill-1", "First file skill", "Body 1.");
|
|
CreateSkillIn(dir2, "file-skill-2", "Second file skill", "Body 2.");
|
|
|
|
var inlineSkill = new AgentInlineSkill("inline-skill", "Inline skill", "Body inline.");
|
|
|
|
var provider = new AgentSkillsProviderBuilder()
|
|
.UseFileSkill(dir1)
|
|
.UseSkills(inlineSkill)
|
|
.UseFileSkill(dir2)
|
|
.UseFileScriptRunner(s_noOpExecutor)
|
|
.Build();
|
|
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — all three skills should be present in alphabetical order in the prompt
|
|
Assert.NotNull(result.Instructions);
|
|
var instructions = result.Instructions!;
|
|
var indexFileSkill1 = instructions.IndexOf("file-skill-1", StringComparison.Ordinal);
|
|
var indexFileSkill2 = instructions.IndexOf("file-skill-2", StringComparison.Ordinal);
|
|
var indexInlineSkill = instructions.IndexOf("inline-skill", StringComparison.Ordinal);
|
|
|
|
Assert.True(indexFileSkill1 >= 0, "file-skill-1 should be present in the instructions.");
|
|
Assert.True(indexFileSkill2 >= 0, "file-skill-2 should be present in the instructions.");
|
|
Assert.True(indexInlineSkill >= 0, "inline-skill should be present in the instructions.");
|
|
|
|
Assert.True(indexFileSkill1 < indexFileSkill2, "file-skill-1 should appear before file-skill-2.");
|
|
Assert.True(indexFileSkill2 < indexInlineSkill, "file-skill-2 should appear before inline-skill.");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Build_MixedSources_AllSkillsDiscoveredAsync()
|
|
{
|
|
// Arrange — use UseSource, UseSkill, and UseFileSkill in mixed order
|
|
string dir = Path.Combine(this._testRoot, "mixed-dir");
|
|
CreateSkillIn(dir, "file-skill", "File skill", "Body file.");
|
|
|
|
var inlineSkill = new AgentInlineSkill("inline-skill", "Inline skill", "Body inline.");
|
|
var customSource = new CountingAgentSkillsSource(
|
|
[
|
|
new AgentInlineSkill("custom-skill", "Custom source skill", "Body custom.")
|
|
]);
|
|
|
|
var provider = new AgentSkillsProviderBuilder()
|
|
.UseSource(customSource)
|
|
.UseSkills(inlineSkill)
|
|
.UseFileSkill(dir)
|
|
.UseFileScriptRunner(s_noOpExecutor)
|
|
.Build();
|
|
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — all skills from all sources are present
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("custom-skill", result.Instructions);
|
|
Assert.Contains("inline-skill", result.Instructions);
|
|
Assert.Contains("file-skill", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_WithScripts_AllToolsRequireApprovalAsync()
|
|
{
|
|
// Arrange — create a skill with a script; all tools should require approval by default
|
|
string skillDir = Path.Combine(this._testRoot, "approval-skill");
|
|
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
|
|
File.WriteAllText(
|
|
Path.Combine(skillDir, "SKILL.md"),
|
|
"---\nname: approval-skill\ndescription: Approval test\n---\nBody.");
|
|
File.WriteAllText(
|
|
Path.Combine(skillDir, "scripts", "run.py"),
|
|
"print('hello')");
|
|
|
|
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
|
var provider = new AgentSkillsProvider(source);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — all tools should be wrapped in ApprovalRequiredAIFunction
|
|
Assert.NotNull(result.Tools);
|
|
var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script");
|
|
Assert.NotNull(scriptTool);
|
|
Assert.IsType<ApprovalRequiredAIFunction>(scriptTool);
|
|
|
|
var loadTool = result.Tools!.FirstOrDefault(t => t.Name == "load_skill");
|
|
Assert.NotNull(loadTool);
|
|
Assert.IsType<ApprovalRequiredAIFunction>(loadTool);
|
|
|
|
var readTool = result.Tools!.FirstOrDefault(t => t.Name == "read_skill_resource");
|
|
Assert.NotNull(readTool);
|
|
Assert.IsType<ApprovalRequiredAIFunction>(readTool);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_DisableLoadSkillApproval_LoadSkillToolNotWrappedAsync()
|
|
{
|
|
// Arrange
|
|
var options = new AgentSkillsProviderOptions { DisableLoadSkillApproval = true };
|
|
var provider = new AgentSkillsProvider([new AgentInlineSkill("test-skill", "Test", "Body.")], options);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — load_skill should NOT be wrapped, others should still be wrapped
|
|
Assert.NotNull(result.Tools);
|
|
var loadTool = result.Tools!.FirstOrDefault(t => t.Name == "load_skill");
|
|
Assert.NotNull(loadTool);
|
|
Assert.IsNotType<ApprovalRequiredAIFunction>(loadTool);
|
|
|
|
var readTool = result.Tools!.FirstOrDefault(t => t.Name == "read_skill_resource");
|
|
Assert.NotNull(readTool);
|
|
Assert.IsType<ApprovalRequiredAIFunction>(readTool);
|
|
|
|
var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script");
|
|
Assert.NotNull(scriptTool);
|
|
Assert.IsType<ApprovalRequiredAIFunction>(scriptTool);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_DisableReadSkillResourceApproval_ReadToolNotWrappedAsync()
|
|
{
|
|
// Arrange
|
|
var options = new AgentSkillsProviderOptions { DisableReadSkillResourceApproval = true };
|
|
var provider = new AgentSkillsProvider([new AgentInlineSkill("test-skill", "Test", "Body.")], options);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — read_skill_resource should NOT be wrapped, others should still be wrapped
|
|
Assert.NotNull(result.Tools);
|
|
var readTool = result.Tools!.FirstOrDefault(t => t.Name == "read_skill_resource");
|
|
Assert.NotNull(readTool);
|
|
Assert.IsNotType<ApprovalRequiredAIFunction>(readTool);
|
|
|
|
var loadTool = result.Tools!.FirstOrDefault(t => t.Name == "load_skill");
|
|
Assert.NotNull(loadTool);
|
|
Assert.IsType<ApprovalRequiredAIFunction>(loadTool);
|
|
|
|
var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script");
|
|
Assert.NotNull(scriptTool);
|
|
Assert.IsType<ApprovalRequiredAIFunction>(scriptTool);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_DisableRunSkillScriptApproval_ScriptToolNotWrappedAsync()
|
|
{
|
|
// Arrange
|
|
var options = new AgentSkillsProviderOptions { DisableRunSkillScriptApproval = true };
|
|
var provider = new AgentSkillsProvider([new AgentInlineSkill("test-skill", "Test", "Body.")], options);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — run_skill_script should NOT be wrapped, others should still be wrapped
|
|
Assert.NotNull(result.Tools);
|
|
var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script");
|
|
Assert.NotNull(scriptTool);
|
|
Assert.IsNotType<ApprovalRequiredAIFunction>(scriptTool);
|
|
|
|
var loadTool = result.Tools!.FirstOrDefault(t => t.Name == "load_skill");
|
|
Assert.NotNull(loadTool);
|
|
Assert.IsType<ApprovalRequiredAIFunction>(loadTool);
|
|
|
|
var readTool = result.Tools!.FirstOrDefault(t => t.Name == "read_skill_resource");
|
|
Assert.NotNull(readTool);
|
|
Assert.IsType<ApprovalRequiredAIFunction>(readTool);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_DisableAllApprovals_NoToolsWrappedAsync()
|
|
{
|
|
// Arrange
|
|
var options = new AgentSkillsProviderOptions
|
|
{
|
|
DisableLoadSkillApproval = true,
|
|
DisableReadSkillResourceApproval = true,
|
|
DisableRunSkillScriptApproval = true,
|
|
};
|
|
var provider = new AgentSkillsProvider([new AgentInlineSkill("test-skill", "Test", "Body.")], options);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — no tools should be wrapped
|
|
Assert.NotNull(result.Tools);
|
|
foreach (var tool in result.Tools!)
|
|
{
|
|
Assert.IsNotType<ApprovalRequiredAIFunction>(tool);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadOnlyToolsAutoApprovalRule_ApprovesReadOnlyToolsAsync()
|
|
{
|
|
// Arrange
|
|
var loadSkillCall = new FunctionCallContent("call1", AgentSkillsProvider.LoadSkillToolName, null);
|
|
var readResourceCall = new FunctionCallContent("call2", AgentSkillsProvider.ReadSkillResourceToolName, null);
|
|
var runScriptCall = new FunctionCallContent("call3", AgentSkillsProvider.RunSkillScriptToolName, null);
|
|
var unrelatedCall = new FunctionCallContent("call4", "some_other_tool", null);
|
|
|
|
// Act & Assert — read-only tools should be approved
|
|
Assert.True(await AgentSkillsProvider.ReadOnlyToolsAutoApprovalRule(loadSkillCall));
|
|
Assert.True(await AgentSkillsProvider.ReadOnlyToolsAutoApprovalRule(readResourceCall));
|
|
|
|
// Act & Assert — script tool and unrelated tools should not be approved
|
|
Assert.False(await AgentSkillsProvider.ReadOnlyToolsAutoApprovalRule(runScriptCall));
|
|
Assert.False(await AgentSkillsProvider.ReadOnlyToolsAutoApprovalRule(unrelatedCall));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AllToolsAutoApprovalRule_ApprovesAllSkillToolsAsync()
|
|
{
|
|
// Arrange
|
|
var loadSkillCall = new FunctionCallContent("call1", AgentSkillsProvider.LoadSkillToolName, null);
|
|
var readResourceCall = new FunctionCallContent("call2", AgentSkillsProvider.ReadSkillResourceToolName, null);
|
|
var runScriptCall = new FunctionCallContent("call3", AgentSkillsProvider.RunSkillScriptToolName, null);
|
|
var unrelatedCall = new FunctionCallContent("call4", "some_other_tool", null);
|
|
|
|
// Act & Assert — all skill tools should be approved
|
|
Assert.True(await AgentSkillsProvider.AllToolsAutoApprovalRule(loadSkillCall));
|
|
Assert.True(await AgentSkillsProvider.AllToolsAutoApprovalRule(readResourceCall));
|
|
Assert.True(await AgentSkillsProvider.AllToolsAutoApprovalRule(runScriptCall));
|
|
|
|
// Act & Assert — unrelated tools should not be approved
|
|
Assert.False(await AgentSkillsProvider.AllToolsAutoApprovalRule(unrelatedCall));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_MultipleInvocations_SourceIsCalledOnceWhenCachedAsync()
|
|
{
|
|
// Arrange — with CachingAgentSkillsSource, the inner source should only be called once
|
|
var source = new CountingAgentSkillsSource(
|
|
[
|
|
new AgentInlineSkill("cached-tools-skill", "Cached tools test", "Body.")
|
|
]);
|
|
var provider = new AgentSkillsProvider(new CachingAgentSkillsSource(source));
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result1 = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var result2 = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert — source should be called exactly once (cached at source level)
|
|
Assert.NotNull(result1.Tools);
|
|
Assert.NotNull(result2.Tools);
|
|
Assert.Equal(1, source.GetSkillsCallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Constructor_SingleDirectory_DiscoverFileSkillsAsync()
|
|
{
|
|
// Arrange
|
|
this.CreateSkill("file-ctor-skill", "File ctor test", "File body.");
|
|
var provider = new AgentSkillsProvider(this._testRoot, s_noOpExecutor);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("file-ctor-skill", result.Instructions);
|
|
Assert.NotNull(result.Tools);
|
|
Assert.Contains(result.Tools!, t => t.Name == "load_skill");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Constructor_MultipleDirectories_DiscoverFileSkillsAsync()
|
|
{
|
|
// Arrange
|
|
string dir1 = Path.Combine(this._testRoot, "dir1");
|
|
string dir2 = Path.Combine(this._testRoot, "dir2");
|
|
Directory.CreateDirectory(dir1);
|
|
Directory.CreateDirectory(dir2);
|
|
CreateSkillIn(dir1, "skill-a", "Skill A", "Body A.");
|
|
CreateSkillIn(dir2, "skill-b", "Skill B", "Body B.");
|
|
|
|
var provider = new AgentSkillsProvider(new[] { dir1, dir2 }, s_noOpExecutor);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("skill-a", result.Instructions);
|
|
Assert.Contains("skill-b", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Constructor_MultipleDirectories_DeduplicatesSkillsByNameAsync()
|
|
{
|
|
// Arrange — same skill name in two directories
|
|
string dir1 = Path.Combine(this._testRoot, "dup1");
|
|
string dir2 = Path.Combine(this._testRoot, "dup2");
|
|
Directory.CreateDirectory(dir1);
|
|
Directory.CreateDirectory(dir2);
|
|
CreateSkillIn(dir1, "dup-skill", "First", "Body 1.");
|
|
CreateSkillIn(dir2, "dup-skill", "Second", "Body 2.");
|
|
|
|
var provider = new AgentSkillsProvider(new[] { dir1, dir2 }, s_noOpExecutor);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
|
|
var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?> { ["skillName"] = "dup-skill" }));
|
|
|
|
// Assert — only first occurrence should survive
|
|
Assert.NotNull(content);
|
|
Assert.Contains("Body 1.", content!.ToString()!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Constructor_InlineSkillsParams_ProvidesSkillsAsync()
|
|
{
|
|
// Arrange
|
|
var skill1 = new AgentInlineSkill("inline-a", "Inline A", "Instructions A.");
|
|
var skill2 = new AgentInlineSkill("inline-b", "Inline B", "Instructions B.");
|
|
var provider = new AgentSkillsProvider(skill1, skill2);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("inline-a", result.Instructions);
|
|
Assert.Contains("inline-b", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Constructor_InlineSkillsEnumerable_ProvidesSkillsAsync()
|
|
{
|
|
// Arrange
|
|
var skills = new List<AgentInlineSkill>
|
|
{
|
|
new("enum-inline-a", "Inline A", "Instructions A."),
|
|
new("enum-inline-b", "Inline B", "Instructions B."),
|
|
};
|
|
var provider = new AgentSkillsProvider(skills);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("enum-inline-a", result.Instructions);
|
|
Assert.Contains("enum-inline-b", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Constructor_InlineSkills_DeduplicatesAsync()
|
|
{
|
|
// Arrange — two inline skills with the same name
|
|
var skill1 = new AgentInlineSkill("dup-inline", "First", "First instructions.");
|
|
var skill2 = new AgentInlineSkill("dup-inline", "Second", "Second instructions.");
|
|
var provider = new AgentSkillsProvider(skill1, skill2);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
|
|
var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?> { ["skillName"] = "dup-inline" }));
|
|
|
|
// Assert — only one occurrence (first)
|
|
Assert.Contains("First instructions.", content!.ToString()!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Constructor_ClassSkillsParams_ProvidesSkillsAsync()
|
|
{
|
|
// Arrange
|
|
var skill = new TestClassSkill("class-a", "Class A", "Class instructions.");
|
|
var provider = new AgentSkillsProvider(skill);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("class-a", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Constructor_ClassSkillsEnumerable_ProvidesSkillsAsync()
|
|
{
|
|
// Arrange
|
|
var skills = new List<AgentSkill>
|
|
{
|
|
new TestClassSkill("enum-class-a", "Class A", "Instructions A."),
|
|
new TestClassSkill("enum-class-b", "Class B", "Instructions B."),
|
|
};
|
|
var provider = new AgentSkillsProvider(skills);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(result.Instructions);
|
|
Assert.Contains("enum-class-a", result.Instructions);
|
|
Assert.Contains("enum-class-b", result.Instructions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Constructor_ClassSkills_DeduplicatesAsync()
|
|
{
|
|
// Arrange — two class skills with the same name
|
|
var skill1 = new TestClassSkill("dup-class", "First", "First instructions.");
|
|
var skill2 = new TestClassSkill("dup-class", "Second", "Second instructions.");
|
|
var provider = new AgentSkillsProvider(skill1, skill2);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
|
|
var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?> { ["skillName"] = "dup-class" }));
|
|
|
|
// Assert — only first occurrence survives
|
|
Assert.Contains("First instructions.", content!.ToString()!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_PassesAgentAndSessionToSourceContextAsync()
|
|
{
|
|
// Arrange
|
|
var capturingSource = new CapturingAgentSkillsSource(
|
|
new AgentInlineSkill("skill-a", "A", "Instructions A."));
|
|
var provider = new AgentSkillsProvider(capturingSource);
|
|
var session = new TestAgentSession();
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session, new AIContext());
|
|
|
|
// Act
|
|
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(capturingSource.LastContext);
|
|
Assert.Same(this._agent, capturingSource.LastContext!.Agent);
|
|
Assert.Same(session, capturingSource.LastContext.Session);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvokingCoreAsync_NullSession_PassesNullSessionToSourceContextAsync()
|
|
{
|
|
// Arrange
|
|
var capturingSource = new CapturingAgentSkillsSource(
|
|
new AgentInlineSkill("skill-a", "A", "Instructions A."));
|
|
var provider = new AgentSkillsProvider(capturingSource);
|
|
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
|
|
|
// Act
|
|
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(capturingSource.LastContext);
|
|
Assert.Same(this._agent, capturingSource.LastContext!.Agent);
|
|
Assert.Null(capturingSource.LastContext.Session);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_DefaultOwnership_DoesNotDisposeProvidedSource()
|
|
{
|
|
// Arrange
|
|
var source = new DisposeTrackingAgentSkillsSource();
|
|
var provider = new AgentSkillsProvider(source);
|
|
|
|
// Act
|
|
provider.Dispose();
|
|
|
|
// Assert — the caller retains ownership of the source by default.
|
|
Assert.Equal(0, source.DisposeCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_WhenOwningProvidedSource_DisposesSource()
|
|
{
|
|
// Arrange
|
|
var source = new DisposeTrackingAgentSkillsSource();
|
|
var provider = new AgentSkillsProvider(source, ownsSource: true);
|
|
|
|
// Act
|
|
provider.Dispose();
|
|
|
|
// Assert
|
|
Assert.Equal(1, source.DisposeCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_WhenOwningProvidedSource_IsIdempotent()
|
|
{
|
|
// Arrange
|
|
var source = new DisposeTrackingAgentSkillsSource();
|
|
var provider = new AgentSkillsProvider(source, ownsSource: true);
|
|
|
|
// Act
|
|
provider.Dispose();
|
|
provider.Dispose();
|
|
|
|
// Assert — the source is disposed exactly once.
|
|
Assert.Equal(1, source.DisposeCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_BuilderBuiltProvider_DisposesSourcePipeline()
|
|
{
|
|
// Arrange — the builder wraps the source in caching/dedup decorators and transfers ownership.
|
|
var source = new DisposeTrackingAgentSkillsSource();
|
|
var provider = new AgentSkillsProviderBuilder()
|
|
.UseSource(source)
|
|
.Build();
|
|
|
|
// Act
|
|
provider.Dispose();
|
|
|
|
// Assert — disposal cascades through the decorator pipeline to the leaf source.
|
|
Assert.Equal(1, source.DisposeCount);
|
|
}
|
|
|
|
private sealed class TestAgentSession : AgentSession;
|
|
|
|
/// <summary>
|
|
/// A test skill source that captures the most recent <see cref="AgentSkillsSourceContext"/> it received.
|
|
/// </summary>
|
|
private sealed class CapturingAgentSkillsSource : AgentSkillsSource
|
|
{
|
|
private readonly IList<AgentSkill> _skills;
|
|
|
|
public CapturingAgentSkillsSource(params AgentSkill[] skills)
|
|
{
|
|
this._skills = skills;
|
|
}
|
|
|
|
public AgentSkillsSourceContext? LastContext { get; private set; }
|
|
|
|
public override Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
|
|
{
|
|
this.LastContext = context;
|
|
return Task.FromResult(this._skills);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A test skill source that counts how many times <see cref="GetSkillsAsync"/> is called.
|
|
/// </summary>
|
|
private sealed class CountingAgentSkillsSource : AgentSkillsSource
|
|
{
|
|
private readonly IList<AgentSkill> _skills;
|
|
private int _callCount;
|
|
|
|
public CountingAgentSkillsSource(IList<AgentSkill> skills)
|
|
{
|
|
this._skills = skills;
|
|
}
|
|
|
|
public int GetSkillsCallCount => this._callCount;
|
|
|
|
public override Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
|
|
{
|
|
Interlocked.Increment(ref this._callCount);
|
|
return Task.FromResult(this._skills);
|
|
}
|
|
}
|
|
|
|
private sealed class TestClassSkill : AgentClassSkill<TestClassSkill>
|
|
{
|
|
private readonly string _instructions;
|
|
|
|
public TestClassSkill(string name, string description, string instructions)
|
|
{
|
|
this.Frontmatter = new AgentSkillFrontmatter(name, description);
|
|
this._instructions = instructions;
|
|
}
|
|
|
|
public override AgentSkillFrontmatter Frontmatter { get; }
|
|
|
|
protected override string Instructions => this._instructions;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A test skill source that records how many times it has been disposed.
|
|
/// </summary>
|
|
private sealed class DisposeTrackingAgentSkillsSource : AgentSkillsSource
|
|
{
|
|
public int DisposeCount { get; private set; }
|
|
|
|
public override Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceContext context, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult<IList<AgentSkill>>([]);
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing)
|
|
{
|
|
this.DisposeCount++;
|
|
}
|
|
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|
|
}
|