mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
2eb0705ee0
* support arguments of string[] shape for file-based skill scripts * suppress breaking changes errors * address feedback * remove unnecessary usung directive
1034 lines
39 KiB
C#
1034 lines
39 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 void MinimalClassSkill_HasNullOverrides_AndSynthesizesContent()
|
|
{
|
|
// Arrange
|
|
var skill = new MinimalClassSkill();
|
|
|
|
// Act & Assert — null overrides
|
|
Assert.Equal("minimal", skill.Frontmatter.Name);
|
|
Assert.Null(skill.Resources);
|
|
Assert.Null(skill.Scripts);
|
|
|
|
// Act & Assert — synthesized XML content
|
|
Assert.Contains("<name>minimal</name>", skill.Content);
|
|
Assert.Contains("<description>A minimal skill.</description>", skill.Content);
|
|
Assert.Contains("<instructions>", skill.Content);
|
|
Assert.Contains("Minimal skill body.", skill.Content);
|
|
Assert.Contains("</instructions>", skill.Content);
|
|
}
|
|
|
|
[Fact]
|
|
public void FullClassSkill_ReturnsOverriddenLists_AndCachesContent()
|
|
{
|
|
// 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(skill.Content, skill.Content);
|
|
|
|
// Act & Assert — Content includes parameter schema from typed script
|
|
Assert.Contains("parameters_schema", skill.Content);
|
|
Assert.Contains("value", skill.Content);
|
|
}
|
|
|
|
[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 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
|
|
var scripts = skill.Scripts;
|
|
|
|
// Assert — all scripts discovered with correct metadata
|
|
Assert.NotNull(scripts);
|
|
Assert.Equal(4, scripts!.Count);
|
|
Assert.Contains(scripts, s => s.Name == "do-work");
|
|
Assert.Contains(scripts, s => s.Name == "DefaultNamed");
|
|
Assert.Contains(scripts, s => s.Name == "append");
|
|
|
|
var processScript = 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 void AttributedFullSkill_IncludesContentWithSchema_AndCachesMembers()
|
|
{
|
|
// Arrange
|
|
var skill = new AttributedFullSkill();
|
|
|
|
// Act & Assert — Content includes reflected resources and scripts
|
|
Assert.Contains("<resources>", skill.Content);
|
|
Assert.Contains("conversion-table", skill.Content);
|
|
Assert.Contains("<scripts>", skill.Content);
|
|
Assert.Contains("convert", skill.Content);
|
|
|
|
// 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 resourcesDiscoveredField = baseType.GetField("_resourcesDiscovered", BindingFlags.Instance | BindingFlags.NonPublic);
|
|
var scriptsDiscoveredField = baseType.GetField("_scriptsDiscovered", BindingFlags.Instance | BindingFlags.NonPublic);
|
|
var reflectedResourcesField = baseType.GetField("_reflectedResources", BindingFlags.Instance | BindingFlags.NonPublic);
|
|
var reflectedScriptsField = baseType.GetField("_reflectedScripts", BindingFlags.Instance | BindingFlags.NonPublic);
|
|
|
|
Assert.NotNull(resourcesDiscoveredField);
|
|
Assert.NotNull(scriptsDiscoveredField);
|
|
Assert.NotNull(reflectedResourcesField);
|
|
Assert.NotNull(reflectedScriptsField);
|
|
Assert.False((bool)resourcesDiscoveredField!.GetValue(skill)!);
|
|
Assert.False((bool)scriptsDiscoveredField!.GetValue(skill)!);
|
|
|
|
// Act & Assert
|
|
Assert.Null(skill.Resources);
|
|
Assert.Null(skill.Scripts);
|
|
Assert.True((bool)resourcesDiscoveredField.GetValue(skill)!);
|
|
Assert.True((bool)scriptsDiscoveredField.GetValue(skill)!);
|
|
Assert.Null(reflectedResourcesField!.GetValue(skill));
|
|
Assert.Null(reflectedScriptsField!.GetValue(skill));
|
|
|
|
// Repeated access should not re-trigger discovery even when discovered value is null.
|
|
Assert.Null(skill.Resources);
|
|
Assert.Null(skill.Scripts);
|
|
Assert.True((bool)resourcesDiscoveredField.GetValue(skill)!);
|
|
Assert.True((bool)scriptsDiscoveredField.GetValue(skill)!);
|
|
Assert.Null(reflectedResourcesField.GetValue(skill));
|
|
Assert.Null(reflectedScriptsField.GetValue(skill));
|
|
}
|
|
|
|
[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![0];
|
|
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 void Content_IncludesDescription_ForReflectedResources()
|
|
{
|
|
// Arrange
|
|
var skill = new AttributedResourcePropertiesSkill();
|
|
|
|
// Act
|
|
var content = skill.Content;
|
|
|
|
// 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
|
|
}
|