mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
bb9ed63a34
* Restore UTF-8 BOMs and fix BuildScriptSchemasBlock doc comment - Restore UTF-8 BOM on all changed files to match repo convention - Fix XML doc: <schema name=...> -> <schema script=...> to match emitted output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review comments: fix doc remarks and rename tests - Update script doc remarks to clarify only parameter schemas are included - Fix grammar: 'arguments format' -> 'argument format' - Rename misleading test methods to match actual assertions - Clarify comment about removed wrapper element Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: SergeyMenshykh <SergeMenshikh@outlook.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1138 lines
42 KiB
C#
1138 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 (with preserved quotes)
|
|
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 no longer includes resources in body; scripts are in script_schemas
|
|
Assert.DoesNotContain("<resources>", await skill.GetContentAsync());
|
|
Assert.Contains("<script_schemas>", 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_DoesNotRenderResources_InBodyAsync()
|
|
{
|
|
// Arrange
|
|
var skill = new AttributedResourcePropertiesSkill();
|
|
|
|
// Act
|
|
var content = await skill.GetContentAsync();
|
|
|
|
// Assert — resources are no longer rendered in body content
|
|
Assert.DoesNotContain("<resources>", 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
|
|
}
|