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