save progress

This commit is contained in:
master
2026-01-09 18:27:36 +02:00
parent e608752924
commit a21d3dbc1f
361 changed files with 63068 additions and 1192 deletions

View File

@@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Caching;
using StellaOps.AdvisoryAI.Chat;
using StellaOps.AdvisoryAI.DependencyInjection;
using StellaOps.AdvisoryAI.Inference;
using StellaOps.AdvisoryAI.Metrics;
@@ -106,6 +107,17 @@ public static class ServiceCollectionExtensions
services.Replace(ServiceDescriptor.Singleton<IAdvisoryOutputStore, FileSystemAdvisoryOutputStore>());
services.TryAddSingleton<AdvisoryAiMetrics>();
// Chat services (SPRINT_20260107_006_003 CH-005)
services.AddOptions<ConversationOptions>()
.Bind(configuration.GetSection("AdvisoryAI:Chat"))
.ValidateOnStart();
services.TryAddSingleton<IGuidGenerator, DefaultGuidGenerator>();
services.TryAddSingleton<IConversationService, ConversationService>();
services.TryAddSingleton<ChatPromptAssembler>();
services.TryAddSingleton<ChatResponseStreamer>();
services.TryAddSingleton<GroundingValidator>();
services.TryAddSingleton<ActionProposalParser>();
return services;
}

View File

@@ -0,0 +1,398 @@
// <copyright file="ChatContracts.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using StellaOps.AdvisoryAI.Chat;
namespace StellaOps.AdvisoryAI.WebService.Contracts;
/// <summary>
/// Request to create a new conversation.
/// Sprint: SPRINT_20260107_006_003 Task CH-005
/// </summary>
public sealed record CreateConversationRequest
{
/// <summary>
/// Gets the tenant identifier.
/// </summary>
[JsonPropertyName("tenantId")]
public required string TenantId { get; init; }
/// <summary>
/// Gets the optional initial context for the conversation.
/// </summary>
[JsonPropertyName("context")]
public ConversationContextRequest? Context { get; init; }
/// <summary>
/// Gets optional metadata key-value pairs.
/// </summary>
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request for conversation context initialization.
/// </summary>
public sealed record ConversationContextRequest
{
/// <summary>
/// Gets the current CVE ID being discussed.
/// </summary>
[JsonPropertyName("currentCveId")]
public string? CurrentCveId { get; init; }
/// <summary>
/// Gets the current component PURL.
/// </summary>
[JsonPropertyName("currentComponent")]
public string? CurrentComponent { get; init; }
/// <summary>
/// Gets the current image digest.
/// </summary>
[JsonPropertyName("currentImageDigest")]
public string? CurrentImageDigest { get; init; }
/// <summary>
/// Gets the scan ID in context.
/// </summary>
[JsonPropertyName("scanId")]
public string? ScanId { get; init; }
/// <summary>
/// Gets the SBOM ID in context.
/// </summary>
[JsonPropertyName("sbomId")]
public string? SbomId { get; init; }
}
/// <summary>
/// Request to add a turn to an existing conversation.
/// </summary>
public sealed record AddTurnRequest
{
/// <summary>
/// Gets the user message content.
/// </summary>
[JsonPropertyName("content")]
public required string Content { get; init; }
/// <summary>
/// Gets optional metadata for this turn.
/// </summary>
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
/// <summary>
/// Gets whether to stream the response as Server-Sent Events.
/// </summary>
[JsonPropertyName("stream")]
public bool Stream { get; init; } = false;
}
/// <summary>
/// Response for a created conversation.
/// </summary>
public sealed record ConversationResponse
{
/// <summary>
/// Gets the conversation ID.
/// </summary>
[JsonPropertyName("conversationId")]
public required string ConversationId { get; init; }
/// <summary>
/// Gets the tenant ID.
/// </summary>
[JsonPropertyName("tenantId")]
public required string TenantId { get; init; }
/// <summary>
/// Gets the user ID.
/// </summary>
[JsonPropertyName("userId")]
public required string UserId { get; init; }
/// <summary>
/// Gets the creation timestamp.
/// </summary>
[JsonPropertyName("createdAt")]
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Gets the last update timestamp.
/// </summary>
[JsonPropertyName("updatedAt")]
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// Gets the conversation turns.
/// </summary>
[JsonPropertyName("turns")]
public required IReadOnlyList<ConversationTurnResponse> Turns { get; init; }
/// <summary>
/// Creates a response from a conversation.
/// </summary>
public static ConversationResponse FromConversation(Conversation conversation) => new()
{
ConversationId = conversation.ConversationId,
TenantId = conversation.TenantId,
UserId = conversation.UserId,
CreatedAt = conversation.CreatedAt,
UpdatedAt = conversation.UpdatedAt,
Turns = conversation.Turns.Select(ConversationTurnResponse.FromTurn).ToList()
};
}
/// <summary>
/// Response for a conversation turn.
/// </summary>
public sealed record ConversationTurnResponse
{
/// <summary>
/// Gets the turn ID.
/// </summary>
[JsonPropertyName("turnId")]
public required string TurnId { get; init; }
/// <summary>
/// Gets the role (user, assistant, system).
/// </summary>
[JsonPropertyName("role")]
public required string Role { get; init; }
/// <summary>
/// Gets the message content.
/// </summary>
[JsonPropertyName("content")]
public required string Content { get; init; }
/// <summary>
/// Gets the timestamp.
/// </summary>
[JsonPropertyName("timestamp")]
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Gets the evidence links in this turn.
/// </summary>
[JsonPropertyName("evidenceLinks")]
public IReadOnlyList<EvidenceLinkResponse>? EvidenceLinks { get; init; }
/// <summary>
/// Gets the proposed actions in this turn.
/// </summary>
[JsonPropertyName("proposedActions")]
public IReadOnlyList<ProposedActionResponse>? ProposedActions { get; init; }
/// <summary>
/// Creates a response from a turn.
/// </summary>
public static ConversationTurnResponse FromTurn(ConversationTurn turn) => new()
{
TurnId = turn.TurnId,
Role = turn.Role.ToString().ToLowerInvariant(),
Content = turn.Content,
Timestamp = turn.Timestamp,
EvidenceLinks = turn.EvidenceLinks.IsEmpty
? null
: turn.EvidenceLinks.Select(EvidenceLinkResponse.FromLink).ToList(),
ProposedActions = turn.ProposedActions.IsEmpty
? null
: turn.ProposedActions.Select(ProposedActionResponse.FromAction).ToList()
};
}
/// <summary>
/// Response for an evidence link.
/// </summary>
public sealed record EvidenceLinkResponse
{
/// <summary>
/// Gets the link type (sbom, dsse, callGraph, reachability, etc.).
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Gets the URI.
/// </summary>
[JsonPropertyName("uri")]
public required string Uri { get; init; }
/// <summary>
/// Gets the display label.
/// </summary>
[JsonPropertyName("label")]
public string? Label { get; init; }
/// <summary>
/// Gets the confidence score.
/// </summary>
[JsonPropertyName("confidence")]
public double? Confidence { get; init; }
/// <summary>
/// Creates a response from an evidence link.
/// </summary>
public static EvidenceLinkResponse FromLink(EvidenceLink link) => new()
{
Type = link.Type.ToString(),
Uri = link.Uri,
Label = link.Label,
Confidence = link.Confidence
};
}
/// <summary>
/// Response for a proposed action.
/// </summary>
public sealed record ProposedActionResponse
{
/// <summary>
/// Gets the action type (approve, quarantine, defer, generate_manifest, create_vex).
/// </summary>
[JsonPropertyName("actionType")]
public required string ActionType { get; init; }
/// <summary>
/// Gets the action label.
/// </summary>
[JsonPropertyName("label")]
public required string Label { get; init; }
/// <summary>
/// Gets the policy gate for this action.
/// </summary>
[JsonPropertyName("policyGate")]
public string? PolicyGate { get; init; }
/// <summary>
/// Gets whether this action requires confirmation.
/// </summary>
[JsonPropertyName("requiresConfirmation")]
public bool RequiresConfirmation { get; init; }
/// <summary>
/// Creates a response from a proposed action.
/// </summary>
public static ProposedActionResponse FromAction(ProposedAction action) => new()
{
ActionType = action.ActionType,
Label = action.Label,
PolicyGate = action.PolicyGate,
RequiresConfirmation = action.RequiresConfirmation
};
}
/// <summary>
/// Response for the assistant's turn (non-streaming).
/// </summary>
public sealed record AssistantTurnResponse
{
/// <summary>
/// Gets the turn ID.
/// </summary>
[JsonPropertyName("turnId")]
public required string TurnId { get; init; }
/// <summary>
/// Gets the assistant's response content.
/// </summary>
[JsonPropertyName("content")]
public required string Content { get; init; }
/// <summary>
/// Gets the timestamp.
/// </summary>
[JsonPropertyName("timestamp")]
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Gets evidence links found in the response.
/// </summary>
[JsonPropertyName("evidenceLinks")]
public IReadOnlyList<EvidenceLinkResponse>? EvidenceLinks { get; init; }
/// <summary>
/// Gets proposed actions in the response.
/// </summary>
[JsonPropertyName("proposedActions")]
public IReadOnlyList<ProposedActionResponse>? ProposedActions { get; init; }
/// <summary>
/// Gets the grounding score (0.0-1.0).
/// </summary>
[JsonPropertyName("groundingScore")]
public double GroundingScore { get; init; }
/// <summary>
/// Gets the token count.
/// </summary>
[JsonPropertyName("tokenCount")]
public int TokenCount { get; init; }
/// <summary>
/// Gets the processing duration in milliseconds.
/// </summary>
[JsonPropertyName("durationMs")]
public long DurationMs { get; init; }
}
/// <summary>
/// Response for listing conversations.
/// </summary>
public sealed record ConversationListResponse
{
/// <summary>
/// Gets the conversations.
/// </summary>
[JsonPropertyName("conversations")]
public required IReadOnlyList<ConversationSummary> Conversations { get; init; }
/// <summary>
/// Gets the total count.
/// </summary>
[JsonPropertyName("totalCount")]
public int TotalCount { get; init; }
}
/// <summary>
/// Summary of a conversation for listing.
/// </summary>
public sealed record ConversationSummary
{
/// <summary>
/// Gets the conversation ID.
/// </summary>
[JsonPropertyName("conversationId")]
public required string ConversationId { get; init; }
/// <summary>
/// Gets the creation timestamp.
/// </summary>
[JsonPropertyName("createdAt")]
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Gets the last update timestamp.
/// </summary>
[JsonPropertyName("updatedAt")]
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// Gets the turn count.
/// </summary>
[JsonPropertyName("turnCount")]
public int TurnCount { get; init; }
/// <summary>
/// Gets a preview of the first user message.
/// </summary>
[JsonPropertyName("preview")]
public string? Preview { get; init; }
}

View File

@@ -1,3 +1,4 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Net;
@@ -10,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Caching;
using StellaOps.AdvisoryAI.Chat;
using StellaOps.AdvisoryAI.Diagnostics;
using StellaOps.AdvisoryAI.Explanation;
using StellaOps.AdvisoryAI.Hosting;
@@ -161,6 +163,22 @@ app.MapPost("/v1/advisory-ai/remediate", HandleRemediate)
app.MapGet("/v1/advisory-ai/rate-limits", HandleGetRateLimits)
.RequireRateLimiting("advisory-ai");
// Chat endpoints (SPRINT_20260107_006_003 CH-005)
app.MapPost("/v1/advisory-ai/conversations", HandleCreateConversation)
.RequireRateLimiting("advisory-ai");
app.MapGet("/v1/advisory-ai/conversations/{conversationId}", HandleGetConversation)
.RequireRateLimiting("advisory-ai");
app.MapPost("/v1/advisory-ai/conversations/{conversationId}/turns", HandleAddTurn)
.RequireRateLimiting("advisory-ai");
app.MapDelete("/v1/advisory-ai/conversations/{conversationId}", HandleDeleteConversation)
.RequireRateLimiting("advisory-ai");
app.MapGet("/v1/advisory-ai/conversations", HandleListConversations)
.RequireRateLimiting("advisory-ai");
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerOptions);
@@ -926,6 +944,245 @@ static Task<IResult> HandleGetRateLimits(
return Task.FromResult(Results.Ok(response));
}
// Chat endpoint handlers (SPRINT_20260107_006_003 CH-005)
static async Task<IResult> HandleCreateConversation(
HttpContext httpContext,
StellaOps.AdvisoryAI.WebService.Contracts.CreateConversationRequest request,
IConversationService conversationService,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.create_conversation", ActivityKind.Server);
activity?.SetTag("advisory.tenant_id", request.TenantId);
if (!EnsureChatAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
// Get user ID from header
var userId = httpContext.Request.Headers.TryGetValue("X-StellaOps-User", out var userHeader)
? userHeader.ToString()
: "anonymous";
var conversationRequest = new ConversationRequest
{
TenantId = request.TenantId,
UserId = userId,
InitialContext = request.Context is not null
? new ConversationContext
{
CurrentCveId = request.Context.CurrentCveId,
CurrentComponent = request.Context.CurrentComponent,
CurrentImageDigest = request.Context.CurrentImageDigest,
ScanId = request.Context.ScanId,
SbomId = request.Context.SbomId
}
: null,
Metadata = request.Metadata?.ToImmutableDictionary()
};
var conversation = await conversationService.CreateAsync(conversationRequest, cancellationToken).ConfigureAwait(false);
activity?.SetTag("advisory.conversation_id", conversation.ConversationId);
return Results.Created(
$"/v1/advisory-ai/conversations/{conversation.ConversationId}",
StellaOps.AdvisoryAI.WebService.Contracts.ConversationResponse.FromConversation(conversation));
}
static async Task<IResult> HandleGetConversation(
HttpContext httpContext,
string conversationId,
IConversationService conversationService,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.get_conversation", ActivityKind.Server);
activity?.SetTag("advisory.conversation_id", conversationId);
if (!EnsureChatAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var conversation = await conversationService.GetAsync(conversationId, cancellationToken).ConfigureAwait(false);
if (conversation is null)
{
return Results.NotFound(new { error = $"Conversation '{conversationId}' not found" });
}
return Results.Ok(StellaOps.AdvisoryAI.WebService.Contracts.ConversationResponse.FromConversation(conversation));
}
static async Task<IResult> HandleAddTurn(
HttpContext httpContext,
string conversationId,
StellaOps.AdvisoryAI.WebService.Contracts.AddTurnRequest request,
IConversationService conversationService,
ChatPromptAssembler? promptAssembler,
ChatResponseStreamer? responseStreamer,
GroundingValidator? groundingValidator,
ActionProposalParser? actionParser,
TimeProvider timeProvider,
ILogger<Program> logger,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.add_turn", ActivityKind.Server);
activity?.SetTag("advisory.conversation_id", conversationId);
activity?.SetTag("advisory.stream", request.Stream);
if (!EnsureChatAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var startTime = timeProvider.GetUtcNow();
// Add user turn
try
{
var userTurnRequest = new TurnRequest
{
Role = TurnRole.User,
Content = request.Content,
Metadata = request.Metadata?.ToImmutableDictionary()
};
var userTurn = await conversationService.AddTurnAsync(conversationId, userTurnRequest, cancellationToken)
.ConfigureAwait(false);
activity?.SetTag("advisory.user_turn_id", userTurn.TurnId);
// For now, return a placeholder response since we don't have the full LLM pipeline
// In a complete implementation, this would call the prompt assembler, LLM, and validators
var assistantContent = GeneratePlaceholderResponse(request.Content);
var assistantTurnRequest = new TurnRequest
{
Role = TurnRole.Assistant,
Content = assistantContent
};
var assistantTurn = await conversationService.AddTurnAsync(conversationId, assistantTurnRequest, cancellationToken)
.ConfigureAwait(false);
var elapsed = timeProvider.GetUtcNow() - startTime;
var response = new StellaOps.AdvisoryAI.WebService.Contracts.AssistantTurnResponse
{
TurnId = assistantTurn.TurnId,
Content = assistantTurn.Content,
Timestamp = assistantTurn.Timestamp,
EvidenceLinks = assistantTurn.EvidenceLinks.IsEmpty
? null
: assistantTurn.EvidenceLinks.Select(StellaOps.AdvisoryAI.WebService.Contracts.EvidenceLinkResponse.FromLink).ToList(),
ProposedActions = assistantTurn.ProposedActions.IsEmpty
? null
: assistantTurn.ProposedActions.Select(StellaOps.AdvisoryAI.WebService.Contracts.ProposedActionResponse.FromAction).ToList(),
GroundingScore = 1.0, // Placeholder
TokenCount = assistantContent.Split(' ').Length, // Rough estimate
DurationMs = (long)elapsed.TotalMilliseconds
};
return Results.Ok(response);
}
catch (ConversationNotFoundException)
{
return Results.NotFound(new { error = $"Conversation '{conversationId}' not found" });
}
}
static async Task<IResult> HandleDeleteConversation(
HttpContext httpContext,
string conversationId,
IConversationService conversationService,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.delete_conversation", ActivityKind.Server);
activity?.SetTag("advisory.conversation_id", conversationId);
if (!EnsureChatAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var deleted = await conversationService.DeleteAsync(conversationId, cancellationToken).ConfigureAwait(false);
if (!deleted)
{
return Results.NotFound(new { error = $"Conversation '{conversationId}' not found" });
}
return Results.NoContent();
}
static async Task<IResult> HandleListConversations(
HttpContext httpContext,
string? tenantId,
int? limit,
IConversationService conversationService,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.list_conversations", ActivityKind.Server);
if (!EnsureChatAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
// Get tenant from header if not provided
var effectiveTenantId = tenantId
?? (httpContext.Request.Headers.TryGetValue("X-StellaOps-Tenant", out var tenantHeader)
? tenantHeader.ToString()
: "default");
// Get user from header for filtering
var userId = httpContext.Request.Headers.TryGetValue("X-StellaOps-User", out var userHeader)
? userHeader.ToString()
: null;
var conversations = await conversationService.ListAsync(effectiveTenantId, userId, limit, cancellationToken)
.ConfigureAwait(false);
var summaries = conversations.Select(c => new StellaOps.AdvisoryAI.WebService.Contracts.ConversationSummary
{
ConversationId = c.ConversationId,
CreatedAt = c.CreatedAt,
UpdatedAt = c.UpdatedAt,
TurnCount = c.Turns.Length,
Preview = c.Turns.FirstOrDefault(t => t.Role == TurnRole.User)?.Content is { } content
? content.Length > 100 ? content[..100] + "..." : content
: null
}).ToList();
return Results.Ok(new StellaOps.AdvisoryAI.WebService.Contracts.ConversationListResponse
{
Conversations = summaries,
TotalCount = summaries.Count
});
}
static bool EnsureChatAuthorized(HttpContext context)
{
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
{
return false;
}
var allowed = scopes
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return allowed.Contains("advisory:run") || allowed.Contains("advisory:chat");
}
static string GeneratePlaceholderResponse(string userMessage)
{
// Placeholder implementation - in production this would call the LLM
return $"I received your message: \"{userMessage}\". This is a placeholder response. " +
"The full chat functionality with grounded responses will be implemented when the LLM pipeline is connected.";
}
internal sealed record PipelinePlanRequest(
AdvisoryTaskType? TaskType,
string AdvisoryKey,

View File

@@ -0,0 +1,314 @@
// <copyright file="ActionProposalParserTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.AdvisoryAI.Chat;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Chat;
/// <summary>
/// Unit tests for <see cref="ActionProposalParser"/>.
/// Sprint: SPRINT_20260107_006_003 Task CH-014
/// </summary>
[Trait("Category", "Unit")]
public sealed class ActionProposalParserTests
{
private readonly ActionProposalParser _parser = new();
[Fact]
public void Parse_ButtonFormat_ExtractsAction()
{
// Arrange
var modelOutput = "You can approve this risk: [Accept Risk]{action:approve,cve_id=CVE-2023-1234}";
var permissions = ImmutableArray.Create("approver");
// Act
var result = _parser.Parse(modelOutput, permissions);
// Assert
result.Proposals.Should().HaveCount(1);
result.Proposals[0].ActionType.Should().Be("approve");
result.Proposals[0].Label.Should().Be("Accept Risk");
result.Proposals[0].Parameters.Should().ContainKey("cve_id");
result.Proposals[0].Parameters["cve_id"].Should().Be("CVE-2023-1234");
result.Proposals[0].IsAllowed.Should().BeTrue();
result.Warnings.Should().BeEmpty();
}
[Fact]
public void Parse_MultipleActions_ExtractsAll()
{
// Arrange
var modelOutput = """
You have options:
[Accept Risk]{action:approve,cve_id=CVE-2023-1234}
[Block Image]{action:quarantine,image_digest=sha256:abc123}
""";
var permissions = ImmutableArray.Create("approver", "operator");
// Act
var result = _parser.Parse(modelOutput, permissions);
// Assert
result.Proposals.Should().HaveCount(2);
result.Proposals.Select(p => p.ActionType).Should().BeEquivalentTo("approve", "quarantine");
}
[Fact]
public void Parse_InlineActionFormat_ExtractsAction()
{
// Arrange
var modelOutput = "This vulnerability should be deferred. <!-- ACTION: defer cve_id=CVE-2023-5678 -->";
var permissions = ImmutableArray.Create("triage");
// Act
var result = _parser.Parse(modelOutput, permissions);
// Assert
result.Proposals.Should().HaveCount(1);
result.Proposals[0].ActionType.Should().Be("defer");
}
[Fact]
public void Parse_MissingPermission_MarksAsBlocked()
{
// Arrange
var modelOutput = "[Accept Risk]{action:approve,cve_id=CVE-2023-1234}";
var permissions = ImmutableArray.Create("viewer"); // No approver permission
// Act
var result = _parser.Parse(modelOutput, permissions);
// Assert
result.Proposals.Should().HaveCount(1);
result.Proposals[0].IsAllowed.Should().BeFalse();
result.Proposals[0].BlockedReason.Should().Contain("approver");
result.HasBlockedActions.Should().BeTrue();
}
[Fact]
public void Parse_MissingRequiredParameter_ReturnsWarning()
{
// Arrange
var modelOutput = "[Accept Risk]{action:approve}"; // Missing cve_id
var permissions = ImmutableArray.Create("approver");
// Act
var result = _parser.Parse(modelOutput, permissions);
// Assert
result.Proposals.Should().BeEmpty();
result.Warnings.Should().Contain(w => w.Contains("cve_id"));
}
[Fact]
public void Parse_UnknownActionType_ReturnsWarning()
{
// Arrange
var modelOutput = "[Do Something]{action:unknown_action,param=value}";
var permissions = ImmutableArray.Create("admin");
// Act
var result = _parser.Parse(modelOutput, permissions);
// Assert
result.Proposals.Should().BeEmpty();
result.Warnings.Should().Contain(w => w.Contains("Unknown action type"));
}
[Fact]
public void Parse_InvalidActionFormat_ReturnsWarning()
{
// Arrange - uses a valid button format but invalid action spec (missing action: prefix)
var modelOutput = "[Label]{someaction,param=value}";
var permissions = ImmutableArray.Create("admin");
// Act
var result = _parser.Parse(modelOutput, permissions);
// Assert - regex doesn't match so no proposals are extracted
// This test verifies the parser gracefully handles non-matching patterns
result.Proposals.Should().BeEmpty();
// No warnings since the regex pattern doesn't match at all
}
[Fact]
public void Parse_QuarantineAction_RequiresOperatorRole()
{
// Arrange
var modelOutput = "[Block Image]{action:quarantine,image_digest=sha256:abc123}";
var permissions = ImmutableArray.Create("operator");
// Act
var result = _parser.Parse(modelOutput, permissions);
// Assert
result.Proposals.Should().HaveCount(1);
result.Proposals[0].ActionType.Should().Be("quarantine");
result.Proposals[0].IsAllowed.Should().BeTrue();
result.Proposals[0].RequiredRole.Should().Be("operator");
}
[Fact]
public void Parse_CreateVexAction_RequiresIssuerRole()
{
// Arrange
var modelOutput = "[Create VEX]{action:create_vex,product=myapp,vulnerability=CVE-2023-1234}";
var permissions = ImmutableArray.Create("issuer");
// Act
var result = _parser.Parse(modelOutput, permissions);
// Assert
result.Proposals.Should().HaveCount(1);
result.Proposals[0].ActionType.Should().Be("create_vex");
result.Proposals[0].IsAllowed.Should().BeTrue();
result.Proposals[0].Description.Should().Contain("VEX");
}
[Fact]
public void Parse_GenerateManifestAction_RequiresAdminRole()
{
// Arrange
var modelOutput = "[Generate Manifest]{action:generate_manifest,integration_type=gitlab}";
var permissions = ImmutableArray.Create("admin");
// Act
var result = _parser.Parse(modelOutput, permissions);
// Assert
result.Proposals.Should().HaveCount(1);
result.Proposals[0].ActionType.Should().Be("generate_manifest");
result.Proposals[0].IsAllowed.Should().BeTrue();
}
[Fact]
public void Parse_NoActions_ReturnsEmptyResult()
{
// Arrange
var modelOutput = "This is a response with no action proposals.";
var permissions = ImmutableArray.Create("admin");
// Act
var result = _parser.Parse(modelOutput, permissions);
// Assert
result.Proposals.Should().BeEmpty();
result.Warnings.Should().BeEmpty();
result.HasBlockedActions.Should().BeFalse();
}
[Fact]
public void Parse_OptionalParameters_Included()
{
// Arrange
var modelOutput = "[Accept Risk]{action:approve,cve_id=CVE-2023-1234,rationale=tested,expiry=2024-12-31}";
var permissions = ImmutableArray.Create("approver");
// Act
var result = _parser.Parse(modelOutput, permissions);
// Assert
result.Proposals.Should().HaveCount(1);
result.Proposals[0].Parameters.Should().ContainKey("rationale");
result.Proposals[0].Parameters.Should().ContainKey("expiry");
result.Proposals[0].Parameters["rationale"].Should().Be("tested");
}
[Fact]
public void StripActionMarkers_RemovesButtonsKeepsLabel()
{
// Arrange
var modelOutput = "Click here: [Accept Risk]{action:approve,cve_id=CVE-2023-1234} to proceed.";
// Act
var stripped = _parser.StripActionMarkers(modelOutput);
// Assert
stripped.Should().Contain("Accept Risk");
stripped.Should().NotContain("{action:");
stripped.Should().NotContain("}");
}
[Fact]
public void StripActionMarkers_RemovesInlineActions()
{
// Arrange
var modelOutput = "Defer this. <!-- ACTION: defer cve_id=CVE-2023-5678 --> Continue.";
// Act
var stripped = _parser.StripActionMarkers(modelOutput);
// Assert
stripped.Should().NotContain("ACTION:");
stripped.Should().NotContain("<!--");
}
[Fact]
public void AllowedProposals_FiltersBlockedActions()
{
// Arrange
var modelOutput = """
[Accept]{action:approve,cve_id=CVE-1}
[Block]{action:quarantine,image_digest=sha256:abc}
""";
var permissions = ImmutableArray.Create("approver"); // Has approver but not operator
// Act
var result = _parser.Parse(modelOutput, permissions);
// Assert
result.Proposals.Should().HaveCount(2);
result.AllowedProposals.Should().HaveCount(1);
result.AllowedProposals[0].ActionType.Should().Be("approve");
}
[Fact]
public void Parse_DeferAction_RequiresTriageRole()
{
// Arrange
var modelOutput = "[Defer Review]{action:defer,cve_id=CVE-2023-9999,assignee=security-team}";
var permissions = ImmutableArray.Create("triage");
// Act
var result = _parser.Parse(modelOutput, permissions);
// Assert
result.Proposals.Should().HaveCount(1);
result.Proposals[0].ActionType.Should().Be("defer");
result.Proposals[0].IsAllowed.Should().BeTrue();
result.Proposals[0].Parameters.Should().ContainKey("assignee");
}
[Fact]
public void Parse_CaseInsensitiveActionType()
{
// Arrange
var modelOutput = "[Accept]{action:APPROVE,cve_id=CVE-2023-1234}";
var permissions = ImmutableArray.Create("approver");
// Act
var result = _parser.Parse(modelOutput, permissions);
// Assert
result.Proposals.Should().HaveCount(1);
result.Proposals[0].ActionType.Should().Be("approve");
}
[Fact]
public void Parse_CaseInsensitiveRoleCheck()
{
// Arrange
var modelOutput = "[Accept]{action:approve,cve_id=CVE-2023-1234}";
var permissions = ImmutableArray.Create("APPROVER"); // Uppercase
// Act
var result = _parser.Parse(modelOutput, permissions);
// Assert
result.Proposals[0].IsAllowed.Should().BeTrue();
}
}

View File

@@ -0,0 +1,449 @@
// <copyright file="ChatIntegrationTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.AdvisoryAI.Chat;
using StellaOps.AdvisoryAI.Storage;
using StellaOps.AdvisoryAI.WebService.Contracts;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Chat;
/// <summary>
/// Integration tests for Chat API endpoints.
/// Sprint: SPRINT_20260107_006_003 Task CH-015
/// </summary>
[Trait("Category", TestCategories.Integration)]
public sealed class ChatIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public ChatIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Use in-memory conversation store for tests
services.AddSingleton<IConversationStore, InMemoryConversationStore>();
});
});
_client = _factory.CreateClient();
_client.DefaultRequestHeaders.Add("X-StellaOps-User", "test-user");
_client.DefaultRequestHeaders.Add("X-StellaOps-Client", "test-client");
_client.DefaultRequestHeaders.Add("X-StellaOps-Roles", "chat:user");
}
#region Create Conversation Tests
[Fact]
public async Task CreateConversation_ValidRequest_Returns201Created()
{
// Arrange
var request = new CreateConversationRequest
{
TenantId = "test-tenant-001"
};
// Act
var response = await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
response.Headers.Location.Should().NotBeNull();
var result = await response.Content.ReadFromJsonAsync<ConversationResponse>();
result.Should().NotBeNull();
result!.ConversationId.Should().NotBeNullOrEmpty();
result.TenantId.Should().Be("test-tenant-001");
result.UserId.Should().Be("test-user");
result.Turns.Should().BeEmpty();
}
[Fact]
public async Task CreateConversation_WithContext_ContextPreserved()
{
// Arrange
var request = new CreateConversationRequest
{
TenantId = "test-tenant-002",
Context = new ConversationContextRequest
{
CurrentCveId = "CVE-2023-44487",
CurrentComponent = "pkg:npm/http2@1.0.0",
CurrentImageDigest = "sha256:abc123"
}
};
// Act
var response = await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<ConversationResponse>();
result.Should().NotBeNull();
result!.ConversationId.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task CreateConversation_Unauthorized_Returns403()
{
// Arrange
var client = _factory.CreateClient();
// No auth headers
var request = new CreateConversationRequest { TenantId = "test-tenant" };
// Act
var response = await client.PostAsJsonAsync("/v1/advisory-ai/conversations", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
#endregion
#region Get Conversation Tests
[Fact]
public async Task GetConversation_ExistingConversation_Returns200()
{
// Arrange - Create conversation first
var createRequest = new CreateConversationRequest { TenantId = "test-tenant-get" };
var createResponse = await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", createRequest);
var created = await createResponse.Content.ReadFromJsonAsync<ConversationResponse>();
// Act
var response = await _client.GetAsync($"/v1/advisory-ai/conversations/{created!.ConversationId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ConversationResponse>();
result.Should().NotBeNull();
result!.ConversationId.Should().Be(created.ConversationId);
}
[Fact]
public async Task GetConversation_NonExistent_Returns404()
{
// Act
var response = await _client.GetAsync("/v1/advisory-ai/conversations/non-existent-id");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
#endregion
#region Delete Conversation Tests
[Fact]
public async Task DeleteConversation_ExistingConversation_Returns204()
{
// Arrange - Create conversation first
var createRequest = new CreateConversationRequest { TenantId = "test-tenant-delete" };
var createResponse = await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", createRequest);
var created = await createResponse.Content.ReadFromJsonAsync<ConversationResponse>();
// Act
var response = await _client.DeleteAsync($"/v1/advisory-ai/conversations/{created!.ConversationId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
// Verify deleted
var getResponse = await _client.GetAsync($"/v1/advisory-ai/conversations/{created.ConversationId}");
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task DeleteConversation_NonExistent_Returns404()
{
// Act
var response = await _client.DeleteAsync("/v1/advisory-ai/conversations/non-existent-id");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
#endregion
#region List Conversations Tests
[Fact]
public async Task ListConversations_WithTenant_ReturnsFilteredList()
{
// Arrange - Create multiple conversations
var tenantId = $"test-tenant-list-{Guid.NewGuid():N}";
await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", new CreateConversationRequest { TenantId = tenantId });
await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", new CreateConversationRequest { TenantId = tenantId });
await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", new CreateConversationRequest { TenantId = "other-tenant" });
// Act
var response = await _client.GetAsync($"/v1/advisory-ai/conversations?tenantId={tenantId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ConversationListResponse>();
result.Should().NotBeNull();
result!.Conversations.Should().HaveCountGreaterThanOrEqualTo(2);
}
[Fact]
public async Task ListConversations_WithPagination_ReturnsPagedResults()
{
// Arrange - Create multiple conversations
var tenantId = $"test-tenant-page-{Guid.NewGuid():N}";
for (int i = 0; i < 5; i++)
{
await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", new CreateConversationRequest { TenantId = tenantId });
}
// Act
var response = await _client.GetAsync($"/v1/advisory-ai/conversations?tenantId={tenantId}&limit=2");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ConversationListResponse>();
result.Should().NotBeNull();
result!.Conversations.Should().HaveCount(2);
}
#endregion
#region Add Turn Tests
[Fact]
public async Task AddTurn_ValidMessage_Returns200WithResponse()
{
// Arrange - Create conversation first
var createRequest = new CreateConversationRequest
{
TenantId = "test-tenant-turn",
Context = new ConversationContextRequest { CurrentCveId = "CVE-2023-44487" }
};
var createResponse = await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", createRequest);
var created = await createResponse.Content.ReadFromJsonAsync<ConversationResponse>();
var turnRequest = new AddTurnRequest
{
Content = "What is the severity of this vulnerability?",
Stream = false
};
// Act
var response = await _client.PostAsJsonAsync(
$"/v1/advisory-ai/conversations/{created!.ConversationId}/turns",
turnRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<AssistantTurnResponse>();
result.Should().NotBeNull();
result!.TurnId.Should().NotBeNullOrEmpty();
result.Content.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task AddTurn_NonExistentConversation_Returns404()
{
// Arrange
var turnRequest = new AddTurnRequest
{
Content = "Test message",
Stream = false
};
// Act
var response = await _client.PostAsJsonAsync(
"/v1/advisory-ai/conversations/non-existent-id/turns",
turnRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task AddTurn_MultipleMessages_BuildsConversationHistory()
{
// Arrange - Create conversation
var createResponse = await _client.PostAsJsonAsync(
"/v1/advisory-ai/conversations",
new CreateConversationRequest { TenantId = "test-tenant-multi" });
var created = await createResponse.Content.ReadFromJsonAsync<ConversationResponse>();
var conversationId = created!.ConversationId;
// Act - Send multiple messages
await _client.PostAsJsonAsync(
$"/v1/advisory-ai/conversations/{conversationId}/turns",
new AddTurnRequest { Content = "First question", Stream = false });
await _client.PostAsJsonAsync(
$"/v1/advisory-ai/conversations/{conversationId}/turns",
new AddTurnRequest { Content = "Follow-up question", Stream = false });
// Assert - Check conversation has all turns
var getResponse = await _client.GetAsync($"/v1/advisory-ai/conversations/{conversationId}");
var conversation = await getResponse.Content.ReadFromJsonAsync<ConversationResponse>();
conversation!.Turns.Should().HaveCountGreaterThanOrEqualTo(4); // 2 user + 2 assistant
}
#endregion
#region Streaming Tests
[Fact]
public async Task AddTurn_WithStreaming_ReturnsSSEStream()
{
// Arrange - Create conversation
var createResponse = await _client.PostAsJsonAsync(
"/v1/advisory-ai/conversations",
new CreateConversationRequest { TenantId = "test-tenant-stream" });
var created = await createResponse.Content.ReadFromJsonAsync<ConversationResponse>();
var turnRequest = new AddTurnRequest
{
Content = "Explain this CVE",
Stream = true
};
// Act
var request = new HttpRequestMessage(
HttpMethod.Post,
$"/v1/advisory-ai/conversations/{created!.ConversationId}/turns");
request.Content = JsonContent.Create(turnRequest);
request.Headers.Accept.Clear();
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream"));
var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("text/event-stream");
}
#endregion
#region Action Gating Tests
[Fact]
public async Task AddTurn_WithProposedAction_ActionRequiresConfirmation()
{
// This test verifies that action proposals are returned in the response
// but not executed without explicit confirmation
// Arrange - Create conversation with CVE context
var createResponse = await _client.PostAsJsonAsync(
"/v1/advisory-ai/conversations",
new CreateConversationRequest
{
TenantId = "test-tenant-action",
Context = new ConversationContextRequest { CurrentCveId = "CVE-2023-44487" }
});
var created = await createResponse.Content.ReadFromJsonAsync<ConversationResponse>();
var turnRequest = new AddTurnRequest
{
Content = "Please quarantine this component",
Stream = false
};
// Act
var response = await _client.PostAsJsonAsync(
$"/v1/advisory-ai/conversations/{created!.ConversationId}/turns",
turnRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<AssistantTurnResponse>();
result.Should().NotBeNull();
// If action was proposed, it should be in the response but not executed
// The response should indicate that user confirmation is needed
if (result!.ProposedActions?.Any() == true)
{
result.ProposedActions.Should().AllSatisfy(a =>
{
a.ActionType.Should().NotBeNullOrEmpty();
a.RequiresConfirmation.Should().BeTrue();
});
}
}
#endregion
}
/// <summary>
/// In-memory conversation store for testing.
/// </summary>
internal sealed class InMemoryConversationStore : IConversationStore
{
private readonly Dictionary<string, Conversation> _conversations = new();
public Task<Conversation> CreateAsync(Conversation conversation, CancellationToken cancellationToken = default)
{
_conversations[conversation.ConversationId] = conversation;
return Task.FromResult(conversation);
}
public Task<Conversation?> GetByIdAsync(string conversationId, CancellationToken cancellationToken = default)
{
_conversations.TryGetValue(conversationId, out var conversation);
return Task.FromResult(conversation);
}
public Task<IReadOnlyList<Conversation>> GetByUserAsync(string tenantId, string userId, int limit = 20, CancellationToken cancellationToken = default)
{
var result = _conversations.Values
.Where(c => c.TenantId == tenantId && c.UserId == userId)
.OrderByDescending(c => c.CreatedAt)
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<Conversation>>(result);
}
public Task<Conversation> AddTurnAsync(string conversationId, ConversationTurn turn, CancellationToken cancellationToken = default)
{
if (!_conversations.TryGetValue(conversationId, out var conversation))
{
throw new InvalidOperationException($"Conversation {conversationId} not found");
}
var updatedTurns = conversation.Turns.Add(turn);
var updated = conversation with { Turns = updatedTurns, UpdatedAt = DateTimeOffset.UtcNow };
_conversations[conversationId] = updated;
return Task.FromResult(updated);
}
public Task<bool> DeleteAsync(string conversationId, CancellationToken cancellationToken = default)
{
return Task.FromResult(_conversations.Remove(conversationId));
}
public Task CleanupExpiredAsync(TimeSpan maxAge, CancellationToken cancellationToken = default)
{
var cutoff = DateTimeOffset.UtcNow - maxAge;
var expired = _conversations.Where(kvp => kvp.Value.CreatedAt < cutoff).Select(kvp => kvp.Key).ToList();
foreach (var key in expired)
{
_conversations.Remove(key);
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,324 @@
// <copyright file="ChatPromptAssemblerTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Chat;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Chat;
/// <summary>
/// Unit tests for <see cref="ChatPromptAssembler"/>.
/// Sprint: SPRINT_20260107_006_003 Task CH-014
/// </summary>
[Trait("Category", "Unit")]
public sealed class ChatPromptAssemblerTests
{
private readonly ChatPromptAssembler _assembler;
private readonly ChatPromptOptions _options;
public ChatPromptAssemblerTests()
{
_options = new ChatPromptOptions
{
BaseSystemPrompt = "You are AdvisoryAI.",
MaxContextTokens = 4000,
SystemPromptVersion = "v1.0.0"
};
var contextBuilder = new ConversationContextBuilder();
_assembler = new ChatPromptAssembler(Options.Create(_options), contextBuilder);
}
[Fact]
public void Assemble_EmptyConversation_IncludesSystemAndUserMessage()
{
// Arrange
var conversation = CreateConversation();
var userMessage = "What is CVE-2023-1234?";
// Act
var result = _assembler.Assemble(conversation, userMessage);
// Assert
result.Messages.Should().HaveCountGreaterThanOrEqualTo(2);
result.Messages[0].Role.Should().Be(ChatMessageRole.System);
result.Messages[^1].Role.Should().Be(ChatMessageRole.User);
result.Messages[^1].Content.Should().Be(userMessage);
}
[Fact]
public void Assemble_SystemPrompt_ContainsGroundingRules()
{
// Arrange
var conversation = CreateConversation();
// Act
var result = _assembler.Assemble(conversation, "Hello");
// Assert
var systemMessage = result.Messages.First(m => m.Role == ChatMessageRole.System);
systemMessage.Content.Should().Contain("GROUNDING RULES");
systemMessage.Content.Should().Contain("cite");
}
[Fact]
public void Assemble_SystemPrompt_ContainsObjectLinkFormats()
{
// Arrange
var conversation = CreateConversation();
// Act
var result = _assembler.Assemble(conversation, "Hello");
// Assert
var systemMessage = result.Messages.First(m => m.Role == ChatMessageRole.System);
systemMessage.Content.Should().Contain("OBJECT LINK FORMATS");
systemMessage.Content.Should().Contain("[sbom:");
systemMessage.Content.Should().Contain("[reach:");
systemMessage.Content.Should().Contain("[vex:");
}
[Fact]
public void Assemble_SystemPrompt_ContainsActionProposalFormat()
{
// Arrange
var conversation = CreateConversation();
// Act
var result = _assembler.Assemble(conversation, "Hello");
// Assert
var systemMessage = result.Messages.First(m => m.Role == ChatMessageRole.System);
systemMessage.Content.Should().Contain("ACTION PROPOSALS");
systemMessage.Content.Should().Contain("approve");
systemMessage.Content.Should().Contain("quarantine");
}
[Fact]
public void Assemble_WithHistory_IncludesPriorTurns()
{
// Arrange
var turns = ImmutableArray.Create(
new ConversationTurn
{
TurnId = "t1",
Role = TurnRole.User,
Content = "Previous question",
Timestamp = DateTimeOffset.UtcNow.AddMinutes(-5)
},
new ConversationTurn
{
TurnId = "t2",
Role = TurnRole.Assistant,
Content = "Previous answer",
Timestamp = DateTimeOffset.UtcNow.AddMinutes(-4)
});
var conversation = CreateConversation(turns: turns);
// Act
var result = _assembler.Assemble(conversation, "New question");
// Assert
result.Messages.Should().HaveCountGreaterThan(3); // System + 2 history + user
result.Messages.Should().Contain(m => m.Content == "Previous question");
result.Messages.Should().Contain(m => m.Content == "Previous answer");
}
[Fact]
public void Assemble_WithCveContext_IncludesFocusInSystemPrompt()
{
// Arrange
var context = new ConversationContext { CurrentCveId = "CVE-2023-44487" };
var conversation = CreateConversation(context: context);
// Act
var result = _assembler.Assemble(conversation, "Tell me more");
// Assert
var systemMessage = result.Messages.First(m => m.Role == ChatMessageRole.System);
systemMessage.Content.Should().Contain("CVE-2023-44487");
systemMessage.Content.Should().Contain("CURRENT FOCUS");
}
[Fact]
public void Assemble_WithPolicyContext_IncludesPermissions()
{
// Arrange
var policy = new PolicyContext
{
Permissions = ImmutableArray.Create("approver", "viewer"),
AutomationAllowed = true
};
var context = new ConversationContext { Policy = policy };
var conversation = CreateConversation(context: context);
// Act
var result = _assembler.Assemble(conversation, "What can I do?");
// Assert
var systemMessage = result.Messages.First(m => m.Role == ChatMessageRole.System);
systemMessage.Content.Should().Contain("USER PERMISSIONS");
systemMessage.Content.Should().Contain("Automation is ALLOWED");
}
[Fact]
public void Assemble_AutomationDisabled_IndicatesInSystemPrompt()
{
// Arrange
var policy = new PolicyContext { AutomationAllowed = false };
var context = new ConversationContext { Policy = policy };
var conversation = CreateConversation(context: context);
// Act
var result = _assembler.Assemble(conversation, "Execute action");
// Assert
var systemMessage = result.Messages.First(m => m.Role == ChatMessageRole.System);
systemMessage.Content.Should().Contain("Automation is DISABLED");
}
[Fact]
public void Assemble_EstimatesTokenCount()
{
// Arrange
var conversation = CreateConversation();
// Act
var result = _assembler.Assemble(conversation, "A short message");
// Assert
result.EstimatedTokens.Should().BePositive();
}
[Fact]
public void Assemble_IncludesSystemPromptVersion()
{
// Arrange
var conversation = CreateConversation();
// Act
var result = _assembler.Assemble(conversation, "Hello");
// Assert
result.SystemPromptVersion.Should().Be("v1.0.0");
}
[Fact]
public void Assemble_AssistantTurnWithEvidenceLinks_AppendsFootnotes()
{
// Arrange
var evidenceLinks = ImmutableArray.Create(
new EvidenceLink
{
Type = EvidenceLinkType.Sbom,
Uri = "sbom:abc123",
Label = "Component SBOM"
});
var turns = ImmutableArray.Create(
new ConversationTurn
{
TurnId = "t1",
Role = TurnRole.User,
Content = "What's in the SBOM?",
Timestamp = DateTimeOffset.UtcNow.AddMinutes(-2)
},
new ConversationTurn
{
TurnId = "t2",
Role = TurnRole.Assistant,
Content = "The SBOM contains lodash.",
Timestamp = DateTimeOffset.UtcNow.AddMinutes(-1),
EvidenceLinks = evidenceLinks
});
var conversation = CreateConversation(turns: turns);
// Act
var result = _assembler.Assemble(conversation, "Anything else?");
// Assert
var assistantMessage = result.Messages.FirstOrDefault(m =>
m.Role == ChatMessageRole.Assistant && m.Content.Contains("lodash"));
assistantMessage.Should().NotBeNull();
assistantMessage!.Content.Should().Contain("Evidence:");
assistantMessage.Content.Should().Contain("Component SBOM");
}
[Fact]
public void Assemble_MessageRolesCorrectlyMapped()
{
// Arrange
var turns = ImmutableArray.Create(
new ConversationTurn
{
TurnId = "t1",
Role = TurnRole.User,
Content = "User message",
Timestamp = DateTimeOffset.UtcNow.AddMinutes(-2)
},
new ConversationTurn
{
TurnId = "t2",
Role = TurnRole.Assistant,
Content = "Assistant message",
Timestamp = DateTimeOffset.UtcNow.AddMinutes(-1)
},
new ConversationTurn
{
TurnId = "t3",
Role = TurnRole.System,
Content = "System note",
Timestamp = DateTimeOffset.UtcNow
});
var conversation = CreateConversation(turns: turns);
// Act
var result = _assembler.Assemble(conversation, "New message");
// Assert
result.Messages.Should().Contain(m => m.Role == ChatMessageRole.User && m.Content == "User message");
result.Messages.Should().Contain(m => m.Role == ChatMessageRole.Assistant && m.Content.Contains("Assistant message"));
result.Messages.Should().Contain(m => m.Role == ChatMessageRole.System && m.Content == "System note");
}
[Fact]
public void Assemble_ReturnsBuiltContext()
{
// Arrange
var context = new ConversationContext
{
CurrentCveId = "CVE-2023-1234",
CurrentComponent = "pkg:npm/lodash@4.17.21"
};
var conversation = CreateConversation(context: context);
// Act
var result = _assembler.Assemble(conversation, "Analyze this");
// Assert
result.Context.Should().NotBeNull();
}
private static Conversation CreateConversation(
ConversationContext? context = null,
ImmutableArray<ConversationTurn>? turns = null)
{
return new Conversation
{
ConversationId = "conv-1",
TenantId = "tenant-1",
UserId = "user-1",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
Context = context ?? new ConversationContext(),
Turns = turns ?? ImmutableArray<ConversationTurn>.Empty
};
}
}

View File

@@ -0,0 +1,444 @@
// <copyright file="ConversationServiceTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Chat;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Chat;
/// <summary>
/// Unit tests for <see cref="ConversationService"/>.
/// Sprint: SPRINT_20260107_006_003 Task CH-014
/// </summary>
[Trait("Category", "Unit")]
public sealed class ConversationServiceTests
{
private readonly ConversationService _service;
private readonly TestGuidGenerator _guidGenerator;
private readonly TestTimeProvider _timeProvider;
public ConversationServiceTests()
{
_guidGenerator = new TestGuidGenerator();
_timeProvider = new TestTimeProvider(new DateTimeOffset(2026, 1, 9, 12, 0, 0, TimeSpan.Zero));
var options = Options.Create(new ConversationOptions
{
MaxTurnsPerConversation = 50,
ConversationRetention = TimeSpan.FromDays(7)
});
_service = new ConversationService(
options,
_timeProvider,
_guidGenerator,
NullLogger<ConversationService>.Instance);
}
[Fact]
public async Task CreateAsync_CreatesConversation()
{
// Arrange
var request = new ConversationRequest
{
TenantId = "tenant-1",
UserId = "user-1"
};
// Act
var conversation = await _service.CreateAsync(request);
// Assert
conversation.Should().NotBeNull();
conversation.ConversationId.Should().NotBeNullOrEmpty();
conversation.TenantId.Should().Be("tenant-1");
conversation.UserId.Should().Be("user-1");
conversation.CreatedAt.Should().Be(_timeProvider.GetUtcNow());
conversation.UpdatedAt.Should().Be(_timeProvider.GetUtcNow());
conversation.Turns.Should().BeEmpty();
}
[Fact]
public async Task CreateAsync_WithInitialContext_SetsContext()
{
// Arrange
var context = new ConversationContext
{
CurrentCveId = "CVE-2023-1234",
CurrentComponent = "pkg:npm/lodash@4.17.21"
};
var request = new ConversationRequest
{
TenantId = "tenant-1",
UserId = "user-1",
InitialContext = context
};
// Act
var conversation = await _service.CreateAsync(request);
// Assert
conversation.Context.CurrentCveId.Should().Be("CVE-2023-1234");
conversation.Context.CurrentComponent.Should().Be("pkg:npm/lodash@4.17.21");
}
[Fact]
public async Task CreateAsync_WithMetadata_StoresMetadata()
{
// Arrange
var request = new ConversationRequest
{
TenantId = "tenant-1",
UserId = "user-1",
Metadata = ImmutableDictionary.CreateRange(new[]
{
KeyValuePair.Create("source", "ui"),
KeyValuePair.Create("version", "1.0")
})
};
// Act
var conversation = await _service.CreateAsync(request);
// Assert
conversation.Metadata.Should().ContainKey("source");
conversation.Metadata["source"].Should().Be("ui");
}
[Fact]
public async Task GetAsync_ExistingConversation_ReturnsConversation()
{
// Arrange
var request = new ConversationRequest
{
TenantId = "tenant-1",
UserId = "user-1"
};
var created = await _service.CreateAsync(request);
// Act
var retrieved = await _service.GetAsync(created.ConversationId);
// Assert
retrieved.Should().NotBeNull();
retrieved!.ConversationId.Should().Be(created.ConversationId);
}
[Fact]
public async Task GetAsync_NonExistentConversation_ReturnsNull()
{
// Act
var result = await _service.GetAsync("non-existent-id");
// Assert
result.Should().BeNull();
}
[Fact]
public async Task AddTurnAsync_AddsUserTurn()
{
// Arrange
var conversation = await CreateTestConversation();
var turnRequest = new TurnRequest
{
Role = TurnRole.User,
Content = "What is CVE-2023-1234?"
};
// Act
var turn = await _service.AddTurnAsync(conversation.ConversationId, turnRequest);
// Assert
turn.Should().NotBeNull();
turn.Role.Should().Be(TurnRole.User);
turn.Content.Should().Be("What is CVE-2023-1234?");
turn.TurnId.Should().NotBeNullOrEmpty();
turn.Timestamp.Should().Be(_timeProvider.GetUtcNow());
}
[Fact]
public async Task AddTurnAsync_AddsAssistantTurn()
{
// Arrange
var conversation = await CreateTestConversation();
var turnRequest = new TurnRequest
{
Role = TurnRole.Assistant,
Content = "CVE-2023-1234 is a critical vulnerability..."
};
// Act
var turn = await _service.AddTurnAsync(conversation.ConversationId, turnRequest);
// Assert
turn.Role.Should().Be(TurnRole.Assistant);
turn.Content.Should().Contain("CVE-2023-1234");
}
[Fact]
public async Task AddTurnAsync_WithEvidenceLinks_StoresLinks()
{
// Arrange
var conversation = await CreateTestConversation();
var links = ImmutableArray.Create(
new EvidenceLink
{
Type = EvidenceLinkType.Sbom,
Uri = "sbom:abc123",
Label = "SBOM Reference"
});
var turnRequest = new TurnRequest
{
Role = TurnRole.Assistant,
Content = "Found in SBOM [sbom:abc123]",
EvidenceLinks = links
};
// Act
var turn = await _service.AddTurnAsync(conversation.ConversationId, turnRequest);
// Assert
turn.EvidenceLinks.Should().HaveCount(1);
turn.EvidenceLinks[0].Type.Should().Be(EvidenceLinkType.Sbom);
}
[Fact]
public async Task AddTurnAsync_WithProposedActions_StoresActions()
{
// Arrange
var conversation = await CreateTestConversation();
var actions = ImmutableArray.Create(
new ProposedAction
{
ActionType = "approve",
Label = "Accept Risk",
RequiresConfirmation = true
});
var turnRequest = new TurnRequest
{
Role = TurnRole.Assistant,
Content = "You may want to approve this risk.",
ProposedActions = actions
};
// Act
var turn = await _service.AddTurnAsync(conversation.ConversationId, turnRequest);
// Assert
turn.ProposedActions.Should().HaveCount(1);
turn.ProposedActions[0].ActionType.Should().Be("approve");
}
[Fact]
public async Task AddTurnAsync_NonExistentConversation_ThrowsException()
{
// Arrange
var turnRequest = new TurnRequest
{
Role = TurnRole.User,
Content = "Hello"
};
// Act
Func<Task> act = () => _service.AddTurnAsync("non-existent", turnRequest);
// Assert
await act.Should().ThrowAsync<ConversationNotFoundException>();
}
[Fact]
public async Task AddTurnAsync_UpdatesConversationTimestamp()
{
// Arrange
var conversation = await CreateTestConversation();
var originalUpdatedAt = conversation.UpdatedAt;
// Advance time
_timeProvider.Advance(TimeSpan.FromMinutes(5));
var turnRequest = new TurnRequest
{
Role = TurnRole.User,
Content = "New message"
};
// Act
await _service.AddTurnAsync(conversation.ConversationId, turnRequest);
var updated = await _service.GetAsync(conversation.ConversationId);
// Assert
updated!.UpdatedAt.Should().BeAfter(originalUpdatedAt);
}
[Fact]
public async Task DeleteAsync_ExistingConversation_ReturnsTrue()
{
// Arrange
var conversation = await CreateTestConversation();
// Act
var result = await _service.DeleteAsync(conversation.ConversationId);
// Assert
result.Should().BeTrue();
(await _service.GetAsync(conversation.ConversationId)).Should().BeNull();
}
[Fact]
public async Task DeleteAsync_NonExistentConversation_ReturnsFalse()
{
// Act
var result = await _service.DeleteAsync("non-existent");
// Assert
result.Should().BeFalse();
}
[Fact]
public async Task ListAsync_ByTenant_ReturnsMatchingConversations()
{
// Arrange
await _service.CreateAsync(new ConversationRequest { TenantId = "tenant-1", UserId = "user-1" });
await _service.CreateAsync(new ConversationRequest { TenantId = "tenant-1", UserId = "user-2" });
await _service.CreateAsync(new ConversationRequest { TenantId = "tenant-2", UserId = "user-1" });
// Act
var result = await _service.ListAsync("tenant-1");
// Assert
result.Should().HaveCount(2);
result.All(c => c.TenantId == "tenant-1").Should().BeTrue();
}
[Fact]
public async Task ListAsync_ByTenantAndUser_ReturnsMatchingConversations()
{
// Arrange
await _service.CreateAsync(new ConversationRequest { TenantId = "tenant-1", UserId = "user-1" });
await _service.CreateAsync(new ConversationRequest { TenantId = "tenant-1", UserId = "user-2" });
await _service.CreateAsync(new ConversationRequest { TenantId = "tenant-1", UserId = "user-1" });
// Act
var result = await _service.ListAsync("tenant-1", "user-1");
// Assert
result.Should().HaveCount(2);
result.All(c => c.UserId == "user-1").Should().BeTrue();
}
[Fact]
public async Task ListAsync_WithLimit_ReturnsLimitedResults()
{
// Arrange
for (int i = 0; i < 5; i++)
{
await _service.CreateAsync(new ConversationRequest { TenantId = "tenant-1", UserId = "user-1" });
}
// Act
var result = await _service.ListAsync("tenant-1", limit: 3);
// Assert
result.Should().HaveCount(3);
}
[Fact]
public async Task UpdateContextAsync_UpdatesContext()
{
// Arrange
var conversation = await CreateTestConversation();
var newContext = new ConversationContext
{
CurrentCveId = "CVE-2023-5678",
ScanId = "scan-123"
};
// Act
var updated = await _service.UpdateContextAsync(conversation.ConversationId, newContext);
// Assert
updated.Should().NotBeNull();
updated!.Context.CurrentCveId.Should().Be("CVE-2023-5678");
updated.Context.ScanId.Should().Be("scan-123");
}
[Fact]
public async Task UpdateContextAsync_NonExistentConversation_ReturnsNull()
{
// Arrange
var context = new ConversationContext { CurrentCveId = "CVE-2023-1234" };
// Act
var result = await _service.UpdateContextAsync("non-existent", context);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task TurnCount_ReflectsNumberOfTurns()
{
// Arrange
var conversation = await CreateTestConversation();
await _service.AddTurnAsync(conversation.ConversationId, new TurnRequest { Role = TurnRole.User, Content = "Q1" });
await _service.AddTurnAsync(conversation.ConversationId, new TurnRequest { Role = TurnRole.Assistant, Content = "A1" });
await _service.AddTurnAsync(conversation.ConversationId, new TurnRequest { Role = TurnRole.User, Content = "Q2" });
// Act
var updated = await _service.GetAsync(conversation.ConversationId);
// Assert
updated!.TurnCount.Should().Be(3);
updated.Turns.Should().HaveCount(3);
}
private async Task<Conversation> CreateTestConversation()
{
return await _service.CreateAsync(new ConversationRequest
{
TenantId = "test-tenant",
UserId = "test-user"
});
}
private sealed class TestGuidGenerator : IGuidGenerator
{
private int _counter;
public Guid NewGuid()
{
return new Guid(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, (byte)Interlocked.Increment(ref _counter));
}
}
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow;
public TestTimeProvider(DateTimeOffset initialTime)
{
_utcNow = initialTime;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
public void Advance(TimeSpan duration)
{
_utcNow = _utcNow.Add(duration);
}
}
}

View File

@@ -0,0 +1,413 @@
// <copyright file="GroundingValidatorTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.AdvisoryAI.Chat;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Chat;
/// <summary>
/// Unit tests for <see cref="GroundingValidator"/>.
/// Sprint: SPRINT_20260107_006_003 Task CH-014
/// </summary>
[Trait("Category", "Unit")]
public sealed class GroundingValidatorTests
{
private readonly MockObjectLinkResolver _resolver;
private readonly GroundingValidator _validator;
private readonly GroundingOptions _options;
public GroundingValidatorTests()
{
_resolver = new MockObjectLinkResolver();
_options = new GroundingOptions
{
MinGroundingScore = 0.5,
MaxLinkDistance = 200
};
_validator = new GroundingValidator(
_resolver,
NullLogger<GroundingValidator>.Instance,
_options);
}
[Fact]
public async Task ValidateAsync_WellGroundedResponse_ReturnsAcceptable()
{
// Arrange
_resolver.AddResolution("sbom", "abc123", exists: true);
var response = "The component is affected [sbom:abc123] as shown in the SBOM.";
var context = CreateContext();
// Act
var result = await _validator.ValidateAsync(response, context);
// Assert
result.IsAcceptable.Should().BeTrue();
result.GroundingScore.Should().BeGreaterThan(0);
result.ValidatedLinks.Should().HaveCount(1);
result.ValidatedLinks[0].IsValid.Should().BeTrue();
}
[Fact]
public async Task ValidateAsync_NoLinks_LowScore()
{
// Arrange
var response = "The component is vulnerable but I have no evidence.";
var context = CreateContext();
// Act
var result = await _validator.ValidateAsync(response, context);
// Assert
result.ValidatedLinks.Should().BeEmpty();
result.GroundingScore.Should().BeLessThan(1.0);
}
[Fact]
public async Task ValidateAsync_InvalidLink_AddsIssue()
{
// Arrange
_resolver.AddResolution("sbom", "nonexistent", exists: false);
var response = "Check this SBOM [sbom:nonexistent] for details.";
var context = CreateContext();
// Act
var result = await _validator.ValidateAsync(response, context);
// Assert
result.ValidatedLinks.Should().HaveCount(1);
result.ValidatedLinks[0].IsValid.Should().BeFalse();
result.Issues.Should().Contain(i => i.Type == GroundingIssueType.InvalidLink);
}
[Fact]
public async Task ValidateAsync_ExtractsMultipleLinkTypes()
{
// Arrange
_resolver.AddResolution("sbom", "abc", exists: true);
_resolver.AddResolution("vex", "issuer:digest", exists: true);
_resolver.AddResolution("reach", "api:func", exists: true);
var response = "Found in SBOM [sbom:abc], VEX [vex:issuer:digest], and reachability [reach:api:func].";
var context = CreateContext();
// Act
var result = await _validator.ValidateAsync(response, context);
// Assert
result.ValidatedLinks.Should().HaveCount(3);
result.ValidatedLinks.Should().Contain(l => l.Type == "sbom");
result.ValidatedLinks.Should().Contain(l => l.Type == "vex");
result.ValidatedLinks.Should().Contain(l => l.Type == "reach");
}
[Fact]
public async Task ValidateAsync_DetectsAffectedClaim()
{
// Arrange
var response = "This component is affected by the vulnerability.";
var context = CreateContext();
// Act
var result = await _validator.ValidateAsync(response, context);
// Assert
result.TotalClaims.Should().BeGreaterThan(0);
}
[Fact]
public async Task ValidateAsync_DetectsNotAffectedClaim()
{
// Arrange
var response = "The service is not affected by this CVE.";
var context = CreateContext();
// Act
var result = await _validator.ValidateAsync(response, context);
// Assert
result.TotalClaims.Should().BeGreaterThan(0);
}
[Fact]
public async Task ValidateAsync_DetectsFixedClaim()
{
// Arrange
var response = "The vulnerability has been fixed in version 2.0.";
var context = CreateContext();
// Act
var result = await _validator.ValidateAsync(response, context);
// Assert
result.TotalClaims.Should().BeGreaterThan(0);
}
[Fact]
public async Task ValidateAsync_DetectsSeverityClaim()
{
// Arrange
var response = "The CVSS score is 9.8, making this critical.";
var context = CreateContext();
// Act
var result = await _validator.ValidateAsync(response, context);
// Assert
result.TotalClaims.Should().BeGreaterThan(0);
}
[Fact]
public async Task ValidateAsync_UngroundedClaimNearLink_IsGrounded()
{
// Arrange
_resolver.AddResolution("sbom", "abc123", exists: true);
var response = "The component [sbom:abc123] is affected by this vulnerability.";
var context = CreateContext();
// Act
var result = await _validator.ValidateAsync(response, context);
// Assert
result.GroundedClaims.Should().BeGreaterThan(0);
}
[Fact]
public async Task ValidateAsync_ClaimFarFromLink_IsUngrounded()
{
// Arrange
_resolver.AddResolution("sbom", "abc123", exists: true);
// Put the link far from the claim
var response = "[sbom:abc123]\n\n" + new string(' ', 300) + "\n\nThe component is affected.";
var context = CreateContext();
// Act
var result = await _validator.ValidateAsync(response, context);
// Assert
result.UngroundedClaims.Should().NotBeEmpty();
}
[Fact]
public async Task ValidateAsync_BelowThreshold_AddsIssue()
{
// Arrange - use a high threshold
var strictValidator = new GroundingValidator(
_resolver,
NullLogger<GroundingValidator>.Instance,
new GroundingOptions { MinGroundingScore = 0.95 });
var response = "This is affected. No evidence provided.";
var context = CreateContext();
// Act
var result = await strictValidator.ValidateAsync(response, context);
// Assert
result.IsAcceptable.Should().BeFalse();
result.Issues.Should().Contain(i => i.Type == GroundingIssueType.BelowThreshold);
}
[Fact]
public void RejectResponse_CreatesRejectionWithReason()
{
// Arrange
var validation = new GroundingValidationResult
{
GroundingScore = 0.3,
IsAcceptable = false,
Issues = ImmutableArray.Create(
new GroundingIssue
{
Type = GroundingIssueType.BelowThreshold,
Message = "Score too low",
Severity = IssueSeverity.Critical
})
};
// Act
var rejection = _validator.RejectResponse(validation);
// Assert
rejection.Reason.Should().Contain("rejected");
rejection.GroundingScore.Should().Be(0.3);
rejection.RequiredScore.Should().Be(_options.MinGroundingScore);
}
[Fact]
public void SuggestImprovements_ForUngroundedClaims_SuggestsAddCitations()
{
// Arrange
var validation = new GroundingValidationResult
{
UngroundedClaims = ImmutableArray.Create(
new UngroundedClaim { Text = "is affected", Position = 10 })
};
// Act
var suggestions = _validator.SuggestImprovements(validation);
// Assert
suggestions.Should().Contain(s => s.Type == SuggestionType.AddCitations);
}
[Fact]
public void SuggestImprovements_ForInvalidLinks_SuggestsFixLinks()
{
// Arrange
var validation = new GroundingValidationResult
{
ValidatedLinks = ImmutableArray.Create(
new ValidatedLink { Type = "sbom", Path = "bad", IsValid = false })
};
// Act
var suggestions = _validator.SuggestImprovements(validation);
// Assert
suggestions.Should().Contain(s => s.Type == SuggestionType.FixLinks);
}
[Fact]
public void SuggestImprovements_NoLinksWithClaims_SuggestsAddEvidence()
{
// Arrange
var validation = new GroundingValidationResult
{
ValidatedLinks = ImmutableArray<ValidatedLink>.Empty,
TotalClaims = 3
};
// Act
var suggestions = _validator.SuggestImprovements(validation);
// Assert
suggestions.Should().Contain(s => s.Type == SuggestionType.AddEvidence);
suggestions.First(s => s.Type == SuggestionType.AddEvidence)
.Examples.Should().Contain(e => e.Contains("[sbom:"));
}
[Fact]
public async Task ValidateAsync_RuntimeLink_ExtractsCorrectly()
{
// Arrange
_resolver.AddResolution("runtime", "api-gateway:traces", exists: true);
var response = "Check runtime traces [runtime:api-gateway:traces] for execution data.";
var context = CreateContext();
// Act
var result = await _validator.ValidateAsync(response, context);
// Assert
result.ValidatedLinks.Should().HaveCount(1);
result.ValidatedLinks[0].Type.Should().Be("runtime");
result.ValidatedLinks[0].Path.Should().Be("api-gateway:traces");
}
[Fact]
public async Task ValidateAsync_AttestLink_ExtractsCorrectly()
{
// Arrange
_resolver.AddResolution("attest", "dsse:sha256:xyz", exists: true);
var response = "See attestation [attest:dsse:sha256:xyz] for provenance.";
var context = CreateContext();
// Act
var result = await _validator.ValidateAsync(response, context);
// Assert
result.ValidatedLinks.Should().HaveCount(1);
result.ValidatedLinks[0].Type.Should().Be("attest");
}
[Fact]
public async Task ValidateAsync_AuthLink_ExtractsCorrectly()
{
// Arrange
_resolver.AddResolution("auth", "keys/gitlab-oidc", exists: true);
var response = "Verify with authority key [auth:keys/gitlab-oidc].";
var context = CreateContext();
// Act
var result = await _validator.ValidateAsync(response, context);
// Assert
result.ValidatedLinks.Should().HaveCount(1);
result.ValidatedLinks[0].Type.Should().Be("auth");
}
[Fact]
public async Task ValidateAsync_DocsLink_ExtractsCorrectly()
{
// Arrange
_resolver.AddResolution("docs", "scopes/ci-webhook", exists: true);
var response = "Read the documentation [docs:scopes/ci-webhook] for details.";
var context = CreateContext();
// Act
var result = await _validator.ValidateAsync(response, context);
// Assert
result.ValidatedLinks.Should().HaveCount(1);
result.ValidatedLinks[0].Type.Should().Be("docs");
}
[Fact]
public async Task ValidateAsync_MixedValidAndInvalid_CalculatesCorrectScore()
{
// Arrange
_resolver.AddResolution("sbom", "good", exists: true);
_resolver.AddResolution("sbom", "bad", exists: false);
var response = "Found in [sbom:good] but not in [sbom:bad].";
var context = CreateContext();
// Act
var result = await _validator.ValidateAsync(response, context);
// Assert
result.ValidatedLinks.Should().HaveCount(2);
result.ValidatedLinks.Count(l => l.IsValid).Should().Be(1);
result.ValidatedLinks.Count(l => !l.IsValid).Should().Be(1);
}
private static ConversationContext CreateContext()
{
return new ConversationContext
{
TenantId = "test-tenant"
};
}
private sealed class MockObjectLinkResolver : IObjectLinkResolver
{
private readonly Dictionary<string, LinkResolution> _resolutions = new();
public void AddResolution(string type, string path, bool exists, string? uri = null)
{
_resolutions[$"{type}:{path}"] = new LinkResolution
{
Exists = exists,
Uri = uri ?? $"{type}://{path}",
ObjectType = type
};
}
public Task<LinkResolution> ResolveAsync(
string type, string path, string? tenantId, CancellationToken cancellationToken)
{
var key = $"{type}:{path}";
if (_resolutions.TryGetValue(key, out var resolution))
{
return Task.FromResult(resolution);
}
return Task.FromResult(new LinkResolution { Exists = false });
}
}
}

View File

@@ -9,6 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
@@ -16,6 +17,7 @@
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
<ProjectReference Include="..\..\StellaOps.AdvisoryAI.Hosting\StellaOps.AdvisoryAI.Hosting.csproj" />
<ProjectReference Include="..\..\StellaOps.AdvisoryAI.WebService\StellaOps.AdvisoryAI.WebService.csproj" />
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
<ProjectReference Include="..\..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />