elsa-core/test/integration/Elsa.AI.IntegrationTests/AIChatEndpointReconnectTests.cs
Sipke Schoorstra 3936258146
Implement Weaver AI Copilot core (#7523)
* Implement Weaver AI Copilot core

* Address Greptile review feedback

* Address Greptile persistence feedback

* Address Greptile orchestration feedback

* Address Greptile tool isolation feedback

* Wire chat audit events

* Stream chat events over SSE

* Use server identity for AI endpoints

* Validate AI proposal persistence

* Isolate AI audit failures

* Enforce AI tool lookup scope

* Support AI tool result continuations

* Handle AI chat reconnects safely

* Tighten AI context and reconnect behavior

* Guard AI conversation and persistence setup

* Persist AI tool-loop progress

* Tighten AI tool registry and reconnect cleanup

* Handle AI preparation failures cleanly

* Order AI tool messages after assistant turns

* Initialize AI provider sessions

* Align AI context capabilities

* Prevent completed AI reconnect replay

* Enforce AI conversation ownership

* Default AI proposal creation time

* Persist AI session and retention defaults

* Allow AI context provider overrides

* Scope AI tool results per turn

* Apply AI provider configuration

* Scope AI proposal reads

* Avoid duplicate AI tool continuations

* Resolve AI tool registry scopes

* Tighten AI reconnect cleanup

* Honor default AI proposal tools

* Pass AI provider session to turns

* Close AI observability gaps

* Fix AI capabilities options alias

* Harden AI orchestration lifetimes

* Track actual AI reconnect conversation

* Address AI audit and context review findings

* Fix AI reconnect and persistence capabilities

* Handle AI session startup failures

* Tighten AI orchestration review gaps

* Warn on placeholder AI context

* Filter disabled AI provider tools

* Add durable AI conversation persistence

* Fix AI orchestrator persistence lifetime

* Handle failed AI reconnect edge cases

* Harden AI reconnect failure handling

* Address AI reconnect and cleanup review gaps

* Tighten AI audit and cleanup persistence

* Keep expired AI cleanup best effort

* Tighten AI tool lookup and cleanup fallback

* Handle AI provider and tenant edge cases

* Tighten AI proposal and agent authorization

* Address AI tool scope cleanup review

* Close remaining AI greptile findings

* Harden AI stores and tool defaults

* Harden AI conversation persistence edge cases

* Cover AI proposal and tool visibility guards

* Fix AI capabilities and audit batch resilience

* Fix AI conversation truncation for unicode

* Resolve remaining AI persistence review nits

* Wire AI conversation persistence option

* Address AI audit and proposal style review

* Fix AI stream truncation surrogate handling

* Address AI context and cleanup review

* Preserve AI titles and tenant tool defaults

* Guard AI conversation user ownership

* Align in-memory AI conversation ownership

* Fix expired AI conversation cleanup tracking

* Harden AI proposal persistence retry

* Tighten AI proposal reads and cleanup SQL

* Harden AI reconnect and provider defaults

* Optimize AI tool listing and message trimming

* Preserve AI conversation timestamps

* Address final AI persistence review nits

* Normalize AI acronym casing

* Address Copilot AI review comments

* Normalize default tenant handling for AI stores

* Harden AI registry and message truncation

* Make AI tool filtering explicit

* Align AI contracts with implementation

* Align remaining AI review contracts

* address greptile ai persistence feedback

* Address Copilot AI persistence feedback

* Address Copilot AI host feedback

* Order persisted AI conversation messages

* Address Copilot chat and cleanup feedback

* Release unused AI reconnect reservations

* Address Copilot AI review feedback

* Address Copilot tool and conversation feedback

* Address Copilot governance feedback

* Address Copilot tool test feedback

* Address AI review follow-ups

* Address Copilot AI follow-ups

* Clean up AI persistence tests

* Address IAITool disposal review

* Address AI integration review follow-ups

* Address AI chat persistence review

* Address AI registry and truncation review

* Enable read-only AI tools by default

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-04 11:35:44 +02:00

298 lines
10 KiB
C#

using Elsa.AI.Abstractions.Contracts;
using Elsa.AI.Abstractions.Models;
using Elsa.Common.Multitenancy;
using Elsa.AI.Host.Options;
using Elsa.AI.Host.Streaming;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using ChatEndpoint = Elsa.AI.Host.Endpoints.AI.Chat.Endpoint;
using MicrosoftOptions = Microsoft.Extensions.Options.Options;
namespace Elsa.AI.IntegrationTests;
public class AIChatEndpointReconnectTests
{
[Fact(DisplayName = "Chat endpoint marks the actual reconnect conversation as connected")]
public async Task ChatEndpointMarksTheActualReconnectConversationAsConnected()
{
var sessionManager = new AIStreamSessionManager();
sessionManager.MarkDisconnected("foreign-conversation", TimeSpan.FromMinutes(5));
sessionManager.MarkDisconnected("actual-conversation", TimeSpan.FromMinutes(5));
var endpoint = new ChatEndpoint(
new ReassignedConversationOrchestrator(),
sessionManager,
MicrosoftOptions.Create(new AIHostOptions()));
SetHttpContext(endpoint, new DefaultHttpContext
{
Response =
{
Body = new MemoryStream()
}
});
await endpoint.HandleAsync(new AIChatRequest
{
ConversationId = "foreign-conversation",
UserId = "user-1",
Message = "Reconnect"
}, CancellationToken.None);
Assert.True(sessionManager.CanReconnect("foreign-conversation"));
Assert.False(sessionManager.CanReconnect("actual-conversation"));
}
[Fact(DisplayName = "Chat endpoint releases reconnect reservation when no stream starts")]
public async Task ChatEndpointReleasesReconnectReservationWhenNoStreamStarts()
{
var sessionManager = new AIStreamSessionManager();
sessionManager.MarkDisconnected("conversation-1", TimeSpan.FromMinutes(5));
var endpoint = new ChatEndpoint(
new EmptyOrchestrator(),
sessionManager,
MicrosoftOptions.Create(new AIHostOptions()));
SetHttpContext(endpoint, new DefaultHttpContext
{
Response =
{
Body = new MemoryStream()
}
});
await endpoint.HandleAsync(new AIChatRequest
{
ConversationId = "conversation-1",
UserId = "user-1",
Message = "Reconnect"
}, CancellationToken.None);
Assert.True(sessionManager.CanReconnect("conversation-1"));
}
[Fact(DisplayName = "Chat endpoint resolves tenant from tenant accessor")]
public async Task ChatEndpointResolvesTenantFromTenantAccessor()
{
var tenantAccessor = new DefaultTenantAccessor();
using var tenantScope = tenantAccessor.PushContext(new Tenant { Id = "tenant-1", Name = "Tenant 1" });
var services = new ServiceCollection();
services.AddSingleton<ITenantAccessor>(tenantAccessor);
await using var provider = services.BuildServiceProvider();
var orchestrator = new CapturingRequestOrchestrator();
var endpoint = new ChatEndpoint(
orchestrator,
new AIStreamSessionManager(),
MicrosoftOptions.Create(new AIHostOptions()));
SetHttpContext(endpoint, new DefaultHttpContext
{
RequestServices = provider,
Response =
{
Body = new MemoryStream()
}
});
await endpoint.HandleAsync(new AIChatRequest
{
ConversationId = "conversation-1",
UserId = "user-1",
Message = "Reconnect"
}, CancellationToken.None);
Assert.Equal("tenant-1", orchestrator.Request!.TenantId);
}
[Fact(DisplayName = "Chat endpoint clears unknown requested agents")]
public async Task ChatEndpointClearsUnknownRequestedAgents()
{
var orchestrator = new CapturingRequestOrchestrator();
var endpoint = new ChatEndpoint(
orchestrator,
new AIStreamSessionManager(),
MicrosoftOptions.Create(new AIHostOptions()));
SetHttpContext(endpoint, new DefaultHttpContext
{
Response =
{
Body = new MemoryStream()
}
});
await endpoint.HandleAsync(new AIChatRequest
{
ConversationId = "conversation-1",
UserId = "user-1",
Agent = "privileged-agent",
Message = "Use a privileged tool"
}, CancellationToken.None);
Assert.Null(orchestrator.Request!.Agent);
}
[Fact(DisplayName = "Chat endpoint forwards known requested agents")]
public async Task ChatEndpointForwardsKnownRequestedAgents()
{
var orchestrator = new CapturingRequestOrchestrator();
var endpoint = new ChatEndpoint(
orchestrator,
new AIStreamSessionManager(),
MicrosoftOptions.Create(new AIHostOptions()));
SetHttpContext(endpoint, new DefaultHttpContext
{
Response =
{
Body = new MemoryStream()
}
});
await endpoint.HandleAsync(new AIChatRequest
{
ConversationId = "conversation-1",
UserId = "user-1",
Agent = "workflow-author",
Message = "Use authoring tools"
}, CancellationToken.None);
Assert.Equal("workflow-author", orchestrator.Request!.Agent);
}
[Fact(DisplayName = "Chat endpoint clears client supplied provider names")]
public async Task ChatEndpointClearsClientSuppliedProviderNames()
{
var orchestrator = new CapturingRequestOrchestrator();
var endpoint = new ChatEndpoint(
orchestrator,
new AIStreamSessionManager(),
MicrosoftOptions.Create(new AIHostOptions()));
SetHttpContext(endpoint, new DefaultHttpContext
{
Response =
{
Body = new MemoryStream()
}
});
await endpoint.HandleAsync(new AIChatRequest
{
ConversationId = "conversation-1",
UserId = "user-1",
ProviderName = "privileged-provider",
Message = "Use a specific provider"
}, CancellationToken.None);
Assert.Null(orchestrator.Request!.ProviderName);
}
[Fact(DisplayName = "Chat endpoint normalizes explicit null request values")]
public async Task ChatEndpointNormalizesExplicitNullRequestValues()
{
var orchestrator = new CapturingRequestOrchestrator();
var endpoint = new ChatEndpoint(
orchestrator,
new AIStreamSessionManager(),
MicrosoftOptions.Create(new AIHostOptions()));
SetHttpContext(endpoint, new DefaultHttpContext
{
Response =
{
Body = new MemoryStream()
}
});
await endpoint.HandleAsync(new AIChatRequest
{
ConversationId = "conversation-1",
UserId = "user-1",
Message = null!,
Attachments = null!
}, CancellationToken.None);
Assert.Equal("", orchestrator.Request!.Message);
Assert.Empty(orchestrator.Request.Attachments);
}
[Fact(DisplayName = "Chat endpoint generates conversation ID for blank values")]
public async Task ChatEndpointGeneratesConversationIdForBlankValues()
{
var orchestrator = new CapturingRequestOrchestrator();
var endpoint = new ChatEndpoint(
orchestrator,
new AIStreamSessionManager(),
MicrosoftOptions.Create(new AIHostOptions()));
SetHttpContext(endpoint, new DefaultHttpContext
{
Response =
{
Body = new MemoryStream()
}
});
await endpoint.HandleAsync(new AIChatRequest
{
ConversationId = " ",
UserId = "user-1",
Message = "Start a conversation"
}, CancellationToken.None);
Assert.False(string.IsNullOrWhiteSpace(orchestrator.Request!.ConversationId));
Assert.NotEqual(" ", orchestrator.Request.ConversationId);
}
private static void SetHttpContext(ChatEndpoint endpoint, HttpContext httpContext)
{
var property = typeof(ChatEndpoint)
.GetProperty(nameof(ChatEndpoint.HttpContext), System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic);
property!.SetValue(endpoint, httpContext);
}
private class EmptyOrchestrator : IAIOrchestrator
{
public async IAsyncEnumerable<AIStreamEvent> ExecuteChatAsync(AIChatRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
yield break;
}
}
private class ReassignedConversationOrchestrator : IAIOrchestrator
{
public async IAsyncEnumerable<AIStreamEvent> ExecuteChatAsync(AIChatRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
yield return new AIStreamEvent
{
Type = "conversation.started",
ConversationId = "actual-conversation",
Sequence = 0,
Timestamp = DateTimeOffset.UtcNow
};
yield return new AIStreamEvent
{
Type = "conversation.completed",
ConversationId = "actual-conversation",
Sequence = 1,
Timestamp = DateTimeOffset.UtcNow
};
}
}
private class CapturingRequestOrchestrator : IAIOrchestrator
{
public AIChatRequest? Request { get; private set; }
public async IAsyncEnumerable<AIStreamEvent> ExecuteChatAsync(AIChatRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
Request = request;
await Task.Yield();
yield return new AIStreamEvent
{
Type = "conversation.completed",
ConversationId = request.ConversationId!,
Sequence = 0,
Timestamp = DateTimeOffset.UtcNow
};
}
}
}