using System.Collections; using System.Reflection; using System.Text.Json; using Microsoft.Extensions.AI; using Morph.AiKit.Agents; using Xunit; namespace Morph.AiKit.Tests; /// /// Canarios do agente-como-dado (T7). O que estes testes protegem nao e a existencia dos /// tipos — o compilador ja faz isso — e sim a UNICA regra estrutural que /// tem: nulo nao envia nada ao provider. /// /// /// A comparacao e feita por reflexao contra um virgem, e nao /// contra uma lista de propriedades escrita a mao, de proposito: uma propriedade nova no /// Microsoft.Extensions.AI entra na comparacao sozinha. Uma lista escrita a mao /// envelheceria em silencio no proximo bump do pin — que e exatamente o defeito que a /// feature inteira existe para matar. /// public sealed class AgentSpecTests { // --------------------------------------------------------------------------------- // A regra estrutural: nulo = nao envia // --------------------------------------------------------------------------------- [Fact] public void FreshOptionsProduceChatOptionsIndistinguishableFromPristine() { var produced = new AgentSpecOptions().ToChatOptions(); var differing = DiffAgainstPristine(produced); Assert.True( differing.Count == 0, $"AgentSpecOptions recem-construida NAO pode enviar opcao nenhuma. Enviou: {string.Join(", ", differing)}"); } [Fact] public void EveryAgentSpecOptionsPropertyIsNullOnAFreshInstance() { var fresh = new AgentSpecOptions(); var nonNull = typeof(AgentSpecOptions) .GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.GetIndexParameters().Length == 0) .Where(p => p.GetValue(fresh) is not null) .Select(p => p.Name) .ToList(); Assert.True( nonNull.Count == 0, $"Toda propriedade de AgentSpecOptions nasce nula. Nasceram preenchidas: {string.Join(", ", nonNull)}"); } [Fact] public void SettingOnlyTemperatureSendsOnlyTemperature() { var produced = new AgentSpecOptions { Temperature = 0.2f }.ToChatOptions(); var differing = DiffAgainstPristine(produced); Assert.Equal(new[] { nameof(ChatOptions.Temperature) }, differing); Assert.Equal(0.2f, produced.Temperature); } [Fact] public void AgentLevelKnobsNeverLeakIntoChatOptions() { // As quatro opcoes de nivel de agente. Nenhuma tem casa em ChatOptions: elas sao // lidas pela AgentFactory (T10) e pela telemetria (T12). Se alguem "resolver" isso // despejando-as em AdditionalProperties, este teste morre — que e o ponto. var produced = new AgentSpecOptions { MaxToolIterations = 7, AllowConcurrentToolInvocation = true, TimeoutSeconds = 30, EnableSensitiveTelemetry = true, }.ToChatOptions(); var differing = DiffAgainstPristine(produced); Assert.True( differing.Count == 0, $"Opcao de nivel de agente vazou para ChatOptions: {string.Join(", ", differing)}"); } // --------------------------------------------------------------------------------- // Mapeamento de cada opcao de nivel de requisicao para a sua casa // --------------------------------------------------------------------------------- [Fact] public void EachRequestLevelOptionLandsInItsOwnChatOptionsHome() { var options = new AgentSpecOptions { Temperature = 0.35f, TopP = 0.9f, MaxOutputTokens = 512, Seed = 1234, StopSequences = [""], ToolMode = ChatToolMode.RequireAny, ReasoningEffort = ReasoningEffort.Medium, ServiceTier = "flex", }; var produced = options.ToChatOptions(); Assert.Equal(0.35f, produced.Temperature); Assert.Equal(0.9f, produced.TopP); Assert.Equal(512, produced.MaxOutputTokens); Assert.Equal(1234L, produced.Seed); Assert.Equal([""], produced.StopSequences); Assert.Same(ChatToolMode.RequireAny, produced.ToolMode); Assert.Equal(ReasoningEffort.Medium, produced.Reasoning?.Effort); // service tier NAO existe em ChatOptions no Microsoft.Extensions.AI 10.9.0 (apurado // por reflexao sobre o assembly do pin) — viaja em AdditionalProperties. Assert.NotNull(produced.AdditionalProperties); Assert.True(produced.AdditionalProperties.TryGetValue( AgentSpecOptions.ServiceTierPropertyName, out var serviceTier)); Assert.Equal("flex", serviceTier); } [Fact] public void AnExplicitlyEmptyStopSequenceListIsSentAsWritten() { // "Nulo nao envia; nao-nulo envia o que voce escreveu." Tratar lista vazia como // nula seria uma excecao escondida na unica regra do tipo. var produced = new AgentSpecOptions { StopSequences = [] }.ToChatOptions(); Assert.Equal([nameof(ChatOptions.StopSequences)], DiffAgainstPristine(produced)); Assert.NotNull(produced.StopSequences); Assert.Empty(produced.StopSequences); } [Fact] public void ApplyToNeverClobbersWhatTheCallerAlreadySet() { // A AgentFactory (T10) preenche Tools e ResponseFormat; ApplyTo nao pode apaga-los. var destination = new ChatOptions { ResponseFormat = ChatResponseFormat.Json, ModelId = "already-chosen", }; var returned = new AgentSpecOptions { Temperature = 0.1f }.ApplyTo(destination); Assert.Same(destination, returned); Assert.Same(ChatResponseFormat.Json, destination.ResponseFormat); Assert.Equal("already-chosen", destination.ModelId); Assert.Equal(0.1f, destination.Temperature); } [Fact] public void ApplyToRejectsANullDestination() => Assert.Throws(() => new AgentSpecOptions().ApplyTo(null!)); // --------------------------------------------------------------------------------- // AgentSpec // --------------------------------------------------------------------------------- [Fact] public void AFreshSpecCarriesNoToolsNoSchemaAndSendsNoOptions() { var spec = new AgentSpec { Alias = "text-default", Instructions = "Seja util." }; Assert.Empty(spec.Tools); Assert.Null(spec.OutputSchema); Assert.Null(spec.Api); Assert.NotNull(spec.Options); Assert.Empty(DiffAgainstPristine(spec.Options.ToChatOptions())); } [Theory] [InlineData("")] [InlineData(" ")] public void ABlankAliasIsRejectedAtConstruction(string blank) => Assert.Throws( () => new AgentSpec { Alias = blank, Instructions = "Seja util." }); [Theory] [InlineData("")] [InlineData(" ")] public void BlankInstructionsAreRejectedAtConstruction(string blank) => Assert.Throws( () => new AgentSpec { Alias = "text-default", Instructions = blank }); [Fact] public void TheSpecApiWinsWhenPresentAndTheAliasWinsWhenItIsNull() { var withoutOverride = new AgentSpec { Alias = "a", Instructions = "i" }; var withOverride = withoutOverride with { Api = AgentApi.Chat }; // alias manda quando a spec nao opinou Assert.Equal(AgentApi.Responses, withoutOverride.ResolveApi(AgentApi.Responses)); Assert.Equal(AgentApi.Chat, withoutOverride.ResolveApi(AgentApi.Chat)); // spec manda quando opinou — inclusive contra o alias Assert.Equal(AgentApi.Chat, withOverride.ResolveApi(AgentApi.Responses)); } [Fact] public void ResponsesIsTheDefaultOfTheEnumBecauseItIsTheHouseDefault() => Assert.Equal(AgentApi.Responses, default(AgentApi)); [Fact] public void AnOutputSchemaSurvivesAsLongAsItsDocumentDoes() { using var document = JsonDocument.Parse("""{"type":"object"}"""); var spec = new AgentSpec { Alias = "text-default", Instructions = "Responda em JSON.", OutputSchema = document.RootElement.Clone(), }; Assert.Equal(JsonValueKind.Object, spec.OutputSchema!.Value.ValueKind); Assert.Equal("object", spec.OutputSchema.Value.GetProperty("type").GetString()); } // --------------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------------- /// /// Nomes das propriedades de em que /// difere de um virgem — ou seja, o conjunto exato do que /// seria enviado ao provider. /// private static IReadOnlyList DiffAgainstPristine(ChatOptions actual) { var pristine = new ChatOptions(); return [.. typeof(ChatOptions) .GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.GetIndexParameters().Length == 0 && p.CanRead) .Where(p => !ValuesEqual(p.GetValue(actual), p.GetValue(pristine))) .Select(p => p.Name) .OrderBy(name => name, StringComparer.Ordinal)]; } private static bool ValuesEqual(object? left, object? right) { if (left is null || right is null) { return left is null && right is null; } if (left is IEnumerable leftItems and not string && right is IEnumerable rightItems and not string) { return leftItems.Cast().SequenceEqual(rightItems.Cast()); } return Equals(left, right); } }