Files
agent-framework/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs
SergeyMenshykh 08541ee5a9 .NET: [Breaking] Refactor AgentSkill API to async resource and script lookup (#6030)
* .NET: Refactor AgentSkill API to async resource and script lookup

Replace property-based AgentSkill.Content, Resources, and Scripts with
async-by-name lookup methods plus boolean availability flags:

- Content (string getter) -> GetContentAsync(CancellationToken)
- Resources (full list) -> HasResources + GetResourceAsync(name, ct)
- Scripts (full list) -> HasScripts + GetScriptAsync(name, ct)

This makes the API friendlier for sources like MCP where enumerating all
resources up front is expensive or impossible, and allows skill implementations
to fetch content lazily.

Subclass changes:
- AgentFileSkill and AgentInlineSkill implement the new async API while
  preserving content caching.
- AgentClassSkill<TSelf> keeps virtual Resources/Scripts properties for
  reflection-based discovery and seals the new HasResources/HasScripts/
  GetResourceAsync/GetScriptAsync overrides. Its previously non-thread-safe
  lazy initialization is replaced with Lazy<T> (default thread-safety) wired
  up in a new protected constructor, so concurrent first-access from multiple
  threads is safe.
- AgentSkillsProvider calls the new async API and exposes 
ead_skill_resource
  / load_skill / 
un_skill_script tools that await the per-name lookups.

Includes baseline CompatibilitySuppressions.xml entries for the removed
property getters.

Tests:
- Direct coverage for HasResources, HasScripts, GetResourceAsync, and
  GetScriptAsync on all three skill implementations (positive, missing-name,
  and no-resources/no-scripts cases).
- Thread-safety regression test for AgentClassSkill<TSelf> that exercises
  concurrent first-access to Resources, Scripts, and GetContentAsync from
  many tasks and asserts all observers see the same cached instance.
- Provider-level coverage for the 
ead_skill_resource tool (invocation +
  error paths) and for the previously untested error paths of load_skill
  and 
un_skill_script (empty names, skill/resource/script not found).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review comments

- Move GetScriptAsync inside try/catch in RunSkillScriptAsync for error-handling parity
- Remove dead _reflectedResources branch from AgentSkillTestExtensions
- Fix XML docs to reference virtual Resources/Scripts properties (not sealed methods)
- Add Async suffix to async test methods per naming convention
- Make no-await tests synchronous to eliminate CS1998

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix formatting: add UTF-8 BOM and remove unused using

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix XML cref: Resources/Scripts are on AgentClassSkill<TSelf>

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove HasResources and HasScripts properties from AgentSkill

Drop the virtual HasResources and HasScripts properties from AgentSkill
and all concrete subclasses (AgentFileSkill, AgentInlineSkill,
AgentClassSkill). AgentSkillsProvider now always includes all three
tools (load_skill, read_skill_resource, run_skill_script) and both
instruction blocks, since the tools already handle missing
resources/scripts gracefully.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add blank line for readability in file-based skills sample

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix HostedAgentSkillsPatternTests for always-included tools

Update assertions to expect read_skill_resource and run_skill_script
tools are always present, matching the new behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-25 17:16:03 +00:00

1140 lines
42 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentClassSkill{TSelf}"/> and <see cref="AgentInMemorySkillsSource"/>.
/// </summary>
public sealed class AgentClassSkillTests
{
[Fact]
public async Task MinimalClassSkill_HasNullOverrides_AndSynthesizesContentAsync()
{
// Arrange
var skill = new MinimalClassSkill();
// Act & Assert — null overrides
Assert.Equal("minimal", skill.Frontmatter.Name);
Assert.Null(skill.Resources);
// Act & Assert — synthesized XML content
Assert.Contains("<name>minimal</name>", await skill.GetContentAsync());
Assert.Contains("<description>A minimal skill.</description>", await skill.GetContentAsync());
Assert.Contains("<instructions>", await skill.GetContentAsync());
Assert.Contains("Minimal skill body.", await skill.GetContentAsync());
Assert.Contains("</instructions>", await skill.GetContentAsync());
}
[Fact]
public async Task FullClassSkill_ReturnsOverriddenLists_AndCachesContentAsync()
{
// Arrange
var skill = new FullClassSkill();
// Act & Assert — overridden resources and scripts
Assert.Single(skill.Resources!);
Assert.Equal("test-resource", skill.Resources![0].Name);
Assert.Single(skill.Scripts!);
Assert.Equal("TestScript", skill.Scripts![0].Name);
// Act & Assert — Content is cached
Assert.Same(await skill.GetContentAsync(), await skill.GetContentAsync());
// Act & Assert — Content includes parameter schema from typed script
Assert.Contains("parameters_schema", await skill.GetContentAsync());
Assert.Contains("value", await skill.GetContentAsync());
}
[Fact]
public void ResourcesAndScripts_CanBeLazyLoaded_AndCached()
{
// Arrange
var skill = new LazyLoadedSkill();
// Act & Assert
Assert.Equal(0, skill.ResourceCreationCount);
Assert.Equal(0, skill.ScriptCreationCount);
var firstResources = skill.Resources;
var firstScripts = skill.Scripts;
var secondResources = skill.Resources;
var secondScripts = skill.Scripts;
Assert.Single(firstResources!);
Assert.Single(firstScripts!);
Assert.Same(firstResources, secondResources);
Assert.Same(firstScripts, secondScripts);
Assert.Equal(1, skill.ResourceCreationCount);
Assert.Equal(1, skill.ScriptCreationCount);
}
[Fact]
public async Task AgentInMemorySkillsSource_ReturnsAllSkillsAsync()
{
// Arrange
var skills = new AgentSkill[] { new MinimalClassSkill(), new FullClassSkill() };
var source = new AgentInMemorySkillsSource(skills);
// Act
var result = await source.GetSkillsAsync(CancellationToken.None);
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("minimal", result[0].Frontmatter.Name);
Assert.Equal("full", result[1].Frontmatter.Name);
}
[Fact]
public void AgentClassSkill_InvalidFrontmatter_ThrowsArgumentException()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => new AgentSkillFrontmatter("INVALID-NAME", "An invalid skill."));
}
[Fact]
public void PartialOverrides_OneCollectionNull_OtherHasValues()
{
// Arrange
var resourceOnly = new ResourceOnlySkill();
var scriptOnly = new ScriptOnlySkill();
// Act & Assert
Assert.Single(resourceOnly.Resources!);
Assert.Null(resourceOnly.Scripts);
Assert.Null(scriptOnly.Resources);
Assert.Single(scriptOnly.Scripts!);
}
[Fact]
public async Task GetResourceAsync_ExistingName_ReturnsResourceAsync()
{
// Arrange
var skill = new FullClassSkill();
// Act
var resource = await skill.GetResourceAsync("test-resource");
// Assert
Assert.NotNull(resource);
Assert.Equal("test-resource", resource!.Name);
}
[Fact]
public async Task GetResourceAsync_NonExistingName_ReturnsNullAsync()
{
// Arrange
var skill = new FullClassSkill();
// Act
var resource = await skill.GetResourceAsync("missing");
// Assert
Assert.Null(resource);
}
[Fact]
public async Task GetResourceAsync_NoResources_ReturnsNullAsync()
{
// Arrange
var skill = new MinimalClassSkill();
// Act
var resource = await skill.GetResourceAsync("anything");
// Assert
Assert.Null(resource);
}
[Fact]
public async Task GetScriptAsync_ExistingName_ReturnsScriptAsync()
{
// Arrange
var skill = new FullClassSkill();
// Act
var script = await skill.GetScriptAsync("TestScript");
// Assert
Assert.NotNull(script);
Assert.Equal("TestScript", script!.Name);
}
[Fact]
public async Task GetScriptAsync_NonExistingName_ReturnsNullAsync()
{
// Arrange
var skill = new FullClassSkill();
// Act
var script = await skill.GetScriptAsync("missing");
// Assert
Assert.Null(script);
}
[Fact]
public async Task GetScriptAsync_NoScripts_ReturnsNullAsync()
{
// Arrange
var skill = new MinimalClassSkill();
// Act
var script = await skill.GetScriptAsync("anything");
// Assert
Assert.Null(script);
}
[Fact]
public async Task ConcurrentAccess_ToReflectedResourcesScriptsAndContent_InvokesDiscoveryOnceAsync()
{
// Regression test for thread-safety of Lazy<T> initialization in AgentClassSkill<TSelf>.
// AttributedFullSkill uses attribute-based discovery (no override), so it exercises
// the base class's Lazy<T> fields rather than a subclass's own caching.
var skill = new AttributedFullSkill();
const int Concurrency = 32;
var resourcesResults = new IReadOnlyList<AgentSkillResource>?[Concurrency];
var scriptsResults = new IReadOnlyList<AgentSkillScript>?[Concurrency];
var contentResults = new string[Concurrency];
// Act — invoke all three accessors concurrently from many threads.
await Task.WhenAll(Enumerable.Range(0, Concurrency).Select(i => Task.Run(async () =>
{
resourcesResults[i] = skill.Resources;
scriptsResults[i] = skill.Scripts;
contentResults[i] = await skill.GetContentAsync();
})));
// Assert — every thread observed the same cached instances (no torn state).
for (int i = 1; i < Concurrency; i++)
{
Assert.Same(resourcesResults[0], resourcesResults[i]);
Assert.Same(scriptsResults[0], scriptsResults[i]);
Assert.Same(contentResults[0], contentResults[i]);
}
}
[Fact]
public async Task CreateScriptAndResource_WithSerializerOptions_HandleCustomTypesAsync()
{
// Arrange
var skill = new CustomTypeSkill();
var jso = SkillTestJsonContext.Default.Options;
// Act — script with custom type deserialization
var script = skill.Scripts![0];
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 5 }, jso);
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
var args = argsDoc.RootElement;
var scriptResult = await script.RunAsync(skill, args, null, CancellationToken.None);
// Assert
Assert.NotNull(scriptResult);
var resultText = scriptResult!.ToString()!;
Assert.Contains("result for test", resultText);
Assert.Contains("5", resultText);
// Act — resource with custom type serialization
var resourceResult = await skill.Resources![0].ReadAsync();
// Assert
Assert.NotNull(resourceResult);
Assert.Contains("dark", resourceResult!.ToString()!);
}
[Fact]
public void Scripts_DiscoveredViaAttribute_WithCorrectNamesAndDescriptions()
{
// Arrange
var skill = new AttributedScriptsSkill();
// Act & Assert — all scripts discovered with correct metadata
Assert.NotNull(skill.Scripts);
Assert.Equal(4, skill.Scripts!.Count);
Assert.Contains(skill.Scripts, s => s.Name == "do-work");
Assert.Contains(skill.Scripts, s => s.Name == "DefaultNamed");
Assert.Contains(skill.Scripts, s => s.Name == "append");
var processScript = skill.Scripts.First(s => s.Name == "process");
Assert.Equal("Processes the input.", processScript.Description);
}
[Fact]
public async Task Scripts_DiscoveredViaAttribute_StaticAndInstance_CanBeInvokedAsync()
{
// Arrange
var skill = new AttributedScriptsSkill();
// Act & Assert — static method
var doWorkScript = skill.Scripts!.First(s => s.Name == "do-work");
using var doWorkDoc = JsonDocument.Parse("""{"input":"hello"}""");
var doWorkResult = await doWorkScript.RunAsync(skill, doWorkDoc.RootElement, null, CancellationToken.None);
Assert.Equal("HELLO", doWorkResult?.ToString());
// Act & Assert — instance method
var appendScript = skill.Scripts!.First(s => s.Name == "append");
using var appendDoc = JsonDocument.Parse("""{"input":"test"}""");
var appendResult = await appendScript.RunAsync(skill, appendDoc.RootElement, null, CancellationToken.None);
Assert.Equal("test-suffix", appendResult?.ToString());
}
[Fact]
public void Resources_DiscoveredViaAttribute_OnProperties_WithCorrectMetadata()
{
// Arrange
var skill = new AttributedResourcePropertiesSkill();
// Act
var resources = skill.Resources;
// Assert — all resources discovered with correct metadata
Assert.NotNull(resources);
Assert.Equal(4, resources!.Count);
Assert.Contains(resources, r => r.Name == "ref-data");
Assert.Contains(resources, r => r.Name == "DefaultNamed");
Assert.Contains(resources, r => r.Name == "static-data");
var describedResource = resources.First(r => r.Name == "data");
Assert.Equal("Some important data.", describedResource.Description);
}
[Fact]
public async Task Resources_DiscoveredViaAttribute_OnProperties_CanBeReadAsync()
{
// Arrange
var skill = new AttributedResourcePropertiesSkill();
// Act & Assert — instance property
var refData = skill.Resources!.First(r => r.Name == "ref-data");
Assert.Equal("Reference content.", (await refData.ReadAsync())?.ToString());
// Act & Assert — static property
var staticData = skill.Resources!.First(r => r.Name == "static-data");
Assert.Equal("Static content.", (await staticData.ReadAsync())?.ToString());
}
[Fact]
public async Task Resources_DiscoveredViaAttribute_OnProperty_InvokedEachTimeAsync()
{
// Arrange
var skill = new AttributedResourceDynamicPropertySkill();
var resource = skill.Resources![0];
// Act
var first = await resource.ReadAsync();
var second = await resource.ReadAsync();
// Assert — property getter is called on each ReadAsync, producing different values
Assert.Equal("call-1", first?.ToString());
Assert.Equal("call-2", second?.ToString());
Assert.Equal(2, skill.CallCount);
}
[Fact]
public void Resources_DiscoveredViaAttribute_OnMethods_WithCorrectMetadata()
{
// Arrange
var skill = new AttributedResourceMethodsSkill();
// Act
var resources = skill.Resources;
// Assert
Assert.NotNull(resources);
Assert.Equal(4, resources!.Count);
Assert.Contains(resources, r => r.Name == "dynamic");
Assert.Contains(resources, r => r.Name == "GetData");
Assert.Contains(resources, r => r.Name == "instance-dynamic");
var describedResource = resources.First(r => r.Name == "info");
Assert.Equal("Returns runtime info.", describedResource.Description);
}
[Fact]
public async Task Resources_DiscoveredViaAttribute_OnMethods_CanBeReadAsync()
{
// Arrange
var skill = new AttributedResourceMethodsSkill();
// Act & Assert — static method
var dynamicResource = skill.Resources!.First(r => r.Name == "dynamic");
Assert.Equal("dynamic-value", (await dynamicResource.ReadAsync())?.ToString());
// Act & Assert — instance method
var instanceResource = skill.Resources!.First(r => r.Name == "instance-dynamic");
Assert.Equal("instance-method-value", (await instanceResource.ReadAsync())?.ToString());
}
[Fact]
public async Task AttributedFullSkill_IncludesContentWithSchema_AndCachesMembersAsync()
{
// Arrange
var skill = new AttributedFullSkill();
// Act & Assert — Content includes reflected resources and scripts
Assert.Contains("<resources>", await skill.GetContentAsync());
Assert.Contains("conversion-table", await skill.GetContentAsync());
Assert.Contains("<scripts>", await skill.GetContentAsync());
Assert.Contains("convert", await skill.GetContentAsync());
// Act & Assert — discovered members are cached
Assert.Same(skill.Resources, skill.Resources);
Assert.Same(skill.Scripts, skill.Scripts);
// Act & Assert — script has parameters schema
var script = skill.Scripts![0];
Assert.NotNull(script.ParametersSchema);
Assert.Contains("value", script.ParametersSchema!.Value.GetRawText());
}
[Fact]
public void NoAttributedMembers_NoOverrides_ReturnsNull()
{
// Arrange — skill with no attributes and no overrides; base discovery returns null (not empty list)
var skill = new NoAttributesNoOverridesSkill();
var baseType = typeof(AgentClassSkill<NoAttributesNoOverridesSkill>);
var resourcesField = baseType.GetField("_resources", BindingFlags.Instance | BindingFlags.NonPublic);
var scriptsField = baseType.GetField("_scripts", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(resourcesField);
Assert.NotNull(scriptsField);
var resourcesLazy = (Lazy<IReadOnlyList<AgentSkillResource>?>)resourcesField!.GetValue(skill)!;
var scriptsLazy = (Lazy<IReadOnlyList<AgentSkillScript>?>)scriptsField!.GetValue(skill)!;
Assert.False(resourcesLazy.IsValueCreated);
Assert.False(scriptsLazy.IsValueCreated);
// Act & Assert
Assert.Null(skill.Resources);
Assert.Null(skill.Scripts);
Assert.True(resourcesLazy.IsValueCreated);
Assert.True(scriptsLazy.IsValueCreated);
Assert.Null(resourcesLazy.Value);
Assert.Null(scriptsLazy.Value);
// Repeated access should not re-trigger discovery even when discovered value is null.
Assert.Null(skill.Resources);
Assert.Null(skill.Scripts);
Assert.True(resourcesLazy.IsValueCreated);
Assert.True(scriptsLazy.IsValueCreated);
Assert.Null(resourcesLazy.Value);
Assert.Null(scriptsLazy.Value);
}
[Fact]
public void SubclassOverride_TakesPrecedence_OverAttributes()
{
// Arrange — skill has attributes AND overrides Resources/Scripts
var skill = new AttributedWithOverrideSkill();
// Act
var resources = skill.Resources;
var scripts = skill.Scripts;
// Assert — overrides win, not reflected members
Assert.NotNull(resources);
Assert.Single(resources!);
Assert.Equal("manual-resource", resources![0].Name);
Assert.NotNull(scripts);
Assert.Single(scripts!);
Assert.Equal("ManualScript", scripts![0].Name);
}
[Fact]
public async Task MixedStaticAndInstance_AllDiscoveredAndInvocableAsync()
{
// Arrange
var skill = new MixedStaticInstanceSkill();
// Act & Assert — correct counts
Assert.NotNull(skill.Resources);
Assert.Equal(2, skill.Resources!.Count);
Assert.NotNull(skill.Scripts);
Assert.Equal(2, skill.Scripts!.Count);
// Act & Assert — all resources produce values
foreach (var resource in skill.Resources!)
{
var value = await resource.ReadAsync();
Assert.NotNull(value);
}
// Act & Assert — all scripts produce values
foreach (var script in skill.Scripts!)
{
var result = await script.RunAsync(skill, null, null, CancellationToken.None);
Assert.NotNull(result);
}
}
[Fact]
public async Task SerializerOptions_UsedForReflectedMembersAsync()
{
// Arrange
var skill = new AttributedSkillWithCustomSerializer();
var jso = SkillTestJsonContext.Default.Options;
// Act & Assert — script with custom JSO
var script = skill.Scripts!.First(s => s.Name == "lookup");
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso);
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
var args = argsDoc.RootElement;
var scriptResult = await script.RunAsync(skill, args, null, CancellationToken.None);
Assert.NotNull(scriptResult);
Assert.Contains("test", scriptResult!.ToString()!);
Assert.Contains("3", scriptResult!.ToString()!);
// Act & Assert — resource with custom JSO
var resourceResult = await skill.Resources![0].ReadAsync();
Assert.NotNull(resourceResult);
Assert.Contains("light", resourceResult!.ToString()!);
}
[Fact]
public async Task Content_IncludesDescription_ForReflectedResourcesAsync()
{
// Arrange
var skill = new AttributedResourcePropertiesSkill();
// Act
var content = await skill.GetContentAsync();
// Assert — descriptions from [Description] attribute appear in synthesized content
Assert.Contains("Some important data.", content);
}
[Fact]
public void IndexerPropertyWithResourceAttribute_ThrowsInvalidOperationException()
{
// Arrange
var skill = new IndexerResourceSkill();
// Act & Assert — accessing Resources triggers discovery which should throw
var ex = Assert.Throws<InvalidOperationException>(() => skill.Resources);
Assert.Contains("indexer", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("IndexerResourceSkill", ex.Message);
}
[Fact]
public void ResourceMethodWithUnsupportedParameters_ThrowsInvalidOperationException()
{
// Arrange
var skill = new UnsupportedParamResourceMethodSkill();
// Act & Assert — accessing Resources triggers discovery which should throw
var ex = Assert.Throws<InvalidOperationException>(() => skill.Resources);
Assert.Contains("content", ex.Message);
Assert.Contains("String", ex.Message);
}
[Fact]
public async Task ResourceMethodWithServiceProviderParam_IsDiscoveredSuccessfullyAsync()
{
// Arrange
var skill = new ServiceProviderResourceMethodSkill();
var sp = new ServiceCollection().BuildServiceProvider();
// Act
var resources = skill.Resources;
// Assert
Assert.NotNull(resources);
Assert.Single(resources!);
Assert.Equal("sp-resource", resources![0].Name);
var value = await resources[0].ReadAsync(sp);
Assert.Equal("from-sp-method", value?.ToString());
}
[Fact]
public async Task ResourceMethodWithCancellationTokenParam_IsDiscoveredSuccessfullyAsync()
{
// Arrange
var skill = new CancellationTokenResourceMethodSkill();
// Act
var resources = skill.Resources;
// Assert
Assert.NotNull(resources);
Assert.Single(resources!);
Assert.Equal("ct-resource", resources![0].Name);
var value = await resources[0].ReadAsync();
Assert.Equal("from-ct-method", value?.ToString());
}
[Fact]
public async Task ResourceMethodWithBothServiceProviderAndCancellationToken_IsDiscoveredSuccessfullyAsync()
{
// Arrange
var skill = new BothParamsResourceMethodSkill();
var sp = new ServiceCollection().BuildServiceProvider();
// Act
var resources = skill.Resources;
// Assert
Assert.NotNull(resources);
Assert.Single(resources!);
Assert.Equal("both-resource", resources![0].Name);
var value = await resources[0].ReadAsync(sp);
Assert.Equal("from-both-method", value?.ToString());
}
[Fact]
public async Task CreateScript_FallsBackToSerializerOptions_WhenNoExplicitJsoAsync()
{
// Arrange
var skill = new CreateMethodsFallbackSkill();
// Act — invoke script that uses custom types, relying on SerializerOptions fallback
var script = skill.Scripts!.First(s => s.Name == "Lookup");
var jso = SkillTestJsonContext.Default.Options;
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "fallback", MaxResults = 7 }, jso);
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
var args = argsDoc.RootElement;
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.Contains("fallback", result!.ToString()!);
Assert.Contains("7", result!.ToString()!);
}
[Fact]
public async Task CreateResource_FallsBackToSerializerOptions_WhenNoExplicitJsoAsync()
{
// Arrange
var skill = new CreateMethodsFallbackSkill();
// Act — read resource that uses custom types, relying on SerializerOptions fallback
var resource = skill.Resources!.First(r => r.Name == "config");
var result = await resource.ReadAsync();
// Assert
Assert.NotNull(result);
Assert.Contains("dark", result!.ToString()!);
}
[Fact]
public async Task CreateScript_UsesExplicitJso_OverSerializerOptionsAsync()
{
// Arrange
var skill = new CreateMethodsExplicitJsoSkill();
// Act — invoke script that passes explicit JSO (should take precedence over SerializerOptions)
var script = skill.Scripts!.First(s => s.Name == "Lookup");
var jso = SkillTestJsonContext.Default.Options;
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "explicit", MaxResults = 2 }, jso);
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
var args = argsDoc.RootElement;
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.Contains("explicit", result!.ToString()!);
Assert.Contains("2", result!.ToString()!);
}
[Fact]
public async Task CreateResource_UsesExplicitJso_OverSerializerOptionsAsync()
{
// Arrange
var skill = new CreateMethodsExplicitJsoSkill();
// Act — read resource that passes explicit JSO (should take precedence over SerializerOptions)
var resource = skill.Resources!.First(r => r.Name == "config");
var result = await resource.ReadAsync();
// Assert
Assert.NotNull(result);
Assert.Contains("explicit-theme", result!.ToString()!);
}
[Fact]
public void DuplicateResourceNames_FromProperties_ThrowsInvalidOperationException()
{
// Arrange
var skill = new DuplicateResourcePropertiesSkill();
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => _ = skill.Resources);
Assert.Contains("data", ex.Message);
Assert.Contains("already has a resource", ex.Message);
}
[Fact]
public void DuplicateResourceNames_FromPropertyAndMethod_ThrowsInvalidOperationException()
{
// Arrange
var skill = new DuplicateResourcePropertyAndMethodSkill();
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => _ = skill.Resources);
Assert.Contains("data", ex.Message);
Assert.Contains("already has a resource", ex.Message);
}
[Fact]
public void DuplicateResourceNames_FromMethods_ThrowsInvalidOperationException()
{
// Arrange
var skill = new DuplicateResourceMethodsSkill();
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => _ = skill.Resources);
Assert.Contains("data", ex.Message);
Assert.Contains("already has a resource", ex.Message);
}
[Fact]
public void DuplicateScriptNames_ThrowsInvalidOperationException()
{
// Arrange
var skill = new DuplicateScriptsSkill();
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => _ = skill.Scripts);
Assert.Contains("do-work", ex.Message);
Assert.Contains("already has a script", ex.Message);
}
#region Test skill classes
private sealed class MinimalClassSkill : AgentClassSkill<MinimalClassSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("minimal", "A minimal skill.");
protected override string Instructions => "Minimal skill body.";
}
private sealed class FullClassSkill : AgentClassSkill<FullClassSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("full", "A full skill with resources and scripts.");
protected override string Instructions => "Full skill body.";
public override IReadOnlyList<AgentSkillResource>? Resources =>
[
this.CreateResource("test-resource", "resource content"),
];
public override IReadOnlyList<AgentSkillScript>? Scripts =>
[
this.CreateScript("TestScript", TestScript),
];
private static string TestScript(double value) =>
JsonSerializer.Serialize(new { result = value * 2 });
}
private sealed class ResourceOnlySkill : AgentClassSkill<ResourceOnlySkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("resource-only", "Skill with resources only.");
protected override string Instructions => "Body.";
public override IReadOnlyList<AgentSkillResource>? Resources =>
[
this.CreateResource("data", "some data"),
];
}
private sealed class ScriptOnlySkill : AgentClassSkill<ScriptOnlySkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("script-only", "Skill with scripts only.");
protected override string Instructions => "Body.";
public override IReadOnlyList<AgentSkillScript>? Scripts =>
[
this.CreateScript("ToUpper", (string input) => input.ToUpperInvariant()),
];
}
private sealed class LazyLoadedSkill : AgentClassSkill<LazyLoadedSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("lazy-loaded", "Skill with lazily created resources and scripts.");
protected override string Instructions => "Body.";
public int ResourceCreationCount { get; private set; }
public int ScriptCreationCount { get; private set; }
private IReadOnlyList<AgentSkillResource>? _resources;
private IReadOnlyList<AgentSkillScript>? _scripts;
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources ??= this.CreateResources();
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts ??= this.CreateScripts();
private IReadOnlyList<AgentSkillResource> CreateResources()
{
this.ResourceCreationCount++;
return [this.CreateResource("lazy-resource", "resource content")];
}
private IReadOnlyList<AgentSkillScript> CreateScripts()
{
this.ScriptCreationCount++;
return [this.CreateScript("LazyScript", () => "done")];
}
}
private sealed class CustomTypeSkill : AgentClassSkill<CustomTypeSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("custom-type-skill", "Skill with custom-typed scripts and resources.");
protected override string Instructions => "Body.";
public override IReadOnlyList<AgentSkillResource>? Resources =>
[
this.CreateResource("config", () => new SkillConfig
{
Theme = "dark",
Verbose = true
}, serializerOptions: SkillTestJsonContext.Default.Options),
];
public override IReadOnlyList<AgentSkillScript>? Scripts =>
[
this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse
{
Items = [$"result for {request.Query}"],
TotalCount = request.MaxResults,
}, serializerOptions: SkillTestJsonContext.Default.Options),
];
}
#pragma warning disable IDE0051 // Remove unused private members
private sealed class AttributedScriptsSkill : AgentClassSkill<AttributedScriptsSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-scripts", "Skill with various attributed scripts.");
protected override string Instructions => "Body.";
[AgentSkillScript("do-work")]
private static string DoWork(string input) => input.ToUpperInvariant();
[AgentSkillScript]
private static string DefaultNamed(string input) => input.ToUpperInvariant();
[AgentSkillScript("process")]
[Description("Processes the input.")]
private static string Process(string input) => input;
[AgentSkillScript("append")]
private string Append(string input) => input + "-suffix";
}
private sealed class AttributedResourcePropertiesSkill : AgentClassSkill<AttributedResourcePropertiesSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-resource-props", "Skill with various attributed resource properties.");
protected override string Instructions => "Body.";
[AgentSkillResource("ref-data")]
public string ReferenceData => "Reference content.";
[AgentSkillResource]
public string DefaultNamed => "Some data.";
[AgentSkillResource("data")]
[Description("Some important data.")]
public string DescribedData => "content";
[AgentSkillResource("static-data")]
public static string StaticData => "Static content.";
}
private sealed class AttributedResourceMethodsSkill : AgentClassSkill<AttributedResourceMethodsSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-resource-methods", "Skill with various attributed resource methods.");
protected override string Instructions => "Body.";
[AgentSkillResource("dynamic")]
private static string GetDynamic() => "dynamic-value";
[AgentSkillResource]
private static string GetData() => "data";
[AgentSkillResource("info")]
[Description("Returns runtime info.")]
private static string GetInfo() => "runtime-info";
[AgentSkillResource("instance-dynamic")]
private string GetValue() => "instance-method-value";
}
private sealed class AttributedFullSkill : AgentClassSkill<AttributedFullSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-full", "Full skill with attributed resources and scripts.");
protected override string Instructions => "Convert units using the table.";
[AgentSkillResource("conversion-table")]
public string ConversionTable => "miles -> km: 1.60934";
[AgentSkillScript("convert")]
private static string Convert(double value, double factor) =>
JsonSerializer.Serialize(new { result = value * factor });
}
private sealed class NoAttributesNoOverridesSkill : AgentClassSkill<NoAttributesNoOverridesSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("no-attrs", "Skill with no attributes or overrides.");
protected override string Instructions => "Body.";
}
private sealed class AttributedWithOverrideSkill : AgentClassSkill<AttributedWithOverrideSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-override", "Skill with attributes and overrides.");
protected override string Instructions => "Body.";
// These attributes should be ignored because Resources/Scripts are overridden.
[AgentSkillResource("ignored-resource")]
public string IgnoredData => "ignored";
[AgentSkillScript("ignored-script")]
private static string IgnoredScript() => "ignored";
public override IReadOnlyList<AgentSkillResource>? Resources =>
[
this.CreateResource("manual-resource", "manual content"),
];
public override IReadOnlyList<AgentSkillScript>? Scripts =>
[
this.CreateScript("ManualScript", () => "manual result"),
];
}
private sealed class AttributedResourceDynamicPropertySkill : AgentClassSkill<AttributedResourceDynamicPropertySkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-resource-dynamic-prop", "Skill with dynamic property resource.");
protected override string Instructions => "Body.";
public int CallCount { get; private set; }
[AgentSkillResource("counter")]
public string Counter => $"call-{++this.CallCount}";
}
private sealed class AttributedSkillWithCustomSerializer : AgentClassSkill<AttributedSkillWithCustomSerializer>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-custom-jso", "Skill with custom serializer options.");
protected override string Instructions => "Body.";
protected override JsonSerializerOptions? SerializerOptions => SkillTestJsonContext.Default.Options;
[AgentSkillResource("config")]
public SkillConfig Config => new() { Theme = "light", Verbose = false };
[AgentSkillScript("lookup")]
private static LookupResponse Lookup(LookupRequest request) => new()
{
Items = [$"result for {request.Query}"],
TotalCount = request.MaxResults,
};
}
private sealed class MixedStaticInstanceSkill : AgentClassSkill<MixedStaticInstanceSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("mixed-static-instance", "Skill with both static and instance members.");
protected override string Instructions => "Body.";
[AgentSkillResource("static-resource")]
public static string StaticResource => "static-value";
[AgentSkillResource("instance-resource")]
public string InstanceResource => "instance-data";
[AgentSkillScript("static-script")]
private static string StaticScript() => "static-result";
[AgentSkillScript("instance-script")]
private string InstanceScript() => "instance-data";
}
private sealed class CreateMethodsFallbackSkill : AgentClassSkill<CreateMethodsFallbackSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("create-fallback", "Skill testing SerializerOptions fallback for CreateScript/CreateResource.");
protected override string Instructions => "Body.";
protected override JsonSerializerOptions? SerializerOptions => SkillTestJsonContext.Default.Options;
public override IReadOnlyList<AgentSkillResource>? Resources =>
[
this.CreateResource("config", () => new SkillConfig
{
Theme = "dark",
Verbose = true,
}),
];
public override IReadOnlyList<AgentSkillScript>? Scripts =>
[
this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse
{
Items = [$"result for {request.Query}"],
TotalCount = request.MaxResults,
}),
];
}
private sealed class CreateMethodsExplicitJsoSkill : AgentClassSkill<CreateMethodsExplicitJsoSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("create-explicit-jso", "Skill testing explicit JSO overrides SerializerOptions.");
protected override string Instructions => "Body.";
// SerializerOptions is intentionally null — explicit JSO passed to CreateScript/CreateResource should be used.
public override IReadOnlyList<AgentSkillResource>? Resources =>
[
this.CreateResource("config", () => new SkillConfig
{
Theme = "explicit-theme",
Verbose = false,
}, serializerOptions: SkillTestJsonContext.Default.Options),
];
public override IReadOnlyList<AgentSkillScript>? Scripts =>
[
this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse
{
Items = [$"result for {request.Query}"],
TotalCount = request.MaxResults,
}, serializerOptions: SkillTestJsonContext.Default.Options),
];
}
private sealed class IndexerResourceSkill : AgentClassSkill<IndexerResourceSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("indexer-skill", "Skill with indexer resource.");
protected override string Instructions => "Body.";
private readonly Dictionary<string, string> _data = new() { ["key"] = "value" };
[AgentSkillResource("indexed")]
public string this[string key] => this._data[key];
}
private sealed class UnsupportedParamResourceMethodSkill : AgentClassSkill<UnsupportedParamResourceMethodSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("unsupported-param-skill", "Skill with unsupported param resource method.");
protected override string Instructions => "Body.";
[AgentSkillResource("bad-resource")]
private static string GetData(string content) => content;
}
private sealed class ServiceProviderResourceMethodSkill : AgentClassSkill<ServiceProviderResourceMethodSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("sp-param-skill", "Skill with IServiceProvider param resource method.");
protected override string Instructions => "Body.";
[AgentSkillResource("sp-resource")]
private static string GetData(IServiceProvider? sp) => "from-sp-method";
}
private sealed class CancellationTokenResourceMethodSkill : AgentClassSkill<CancellationTokenResourceMethodSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("ct-param-skill", "Skill with CancellationToken param resource method.");
protected override string Instructions => "Body.";
[AgentSkillResource("ct-resource")]
private static string GetData(CancellationToken ct) => "from-ct-method";
}
private sealed class BothParamsResourceMethodSkill : AgentClassSkill<BothParamsResourceMethodSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("both-param-skill", "Skill with both IServiceProvider and CancellationToken param resource method.");
protected override string Instructions => "Body.";
[AgentSkillResource("both-resource")]
private static string GetData(IServiceProvider? sp, CancellationToken ct) => "from-both-method";
}
private sealed class DuplicateResourcePropertiesSkill : AgentClassSkill<DuplicateResourcePropertiesSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-res-props", "Skill with duplicate resource property names.");
protected override string Instructions => "Body.";
[AgentSkillResource("data")]
public string Data1 => "value1";
[AgentSkillResource("data")]
public string Data2 => "value2";
}
private sealed class DuplicateResourcePropertyAndMethodSkill : AgentClassSkill<DuplicateResourcePropertyAndMethodSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-res-prop-method", "Skill with duplicate resource from property and method.");
protected override string Instructions => "Body.";
[AgentSkillResource("data")]
public string Data => "property-value";
[AgentSkillResource("data")]
private static string GetData() => "method-value";
}
private sealed class DuplicateResourceMethodsSkill : AgentClassSkill<DuplicateResourceMethodsSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-res-methods", "Skill with duplicate resource method names.");
protected override string Instructions => "Body.";
[AgentSkillResource("data")]
private static string GetData1() => "value1";
[AgentSkillResource("data")]
private static string GetData2() => "value2";
}
private sealed class DuplicateScriptsSkill : AgentClassSkill<DuplicateScriptsSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-scripts", "Skill with duplicate script names.");
protected override string Instructions => "Body.";
[AgentSkillScript("do-work")]
private static string DoWork1(string input) => input.ToUpperInvariant();
[AgentSkillScript("do-work")]
private static string DoWork2(string input) => input + "-suffix";
}
#pragma warning restore IDE0051 // Remove unused private members
#endregion
}