elsa-core/test/integration/Elsa.AI.IntegrationTests/AIRuntimeGroundingToolTests.cs

143 lines
8 KiB
C#
Raw Normal View History

using System.Text.Json.Nodes;
using Elsa.AI.Abstractions.Contracts;
using Elsa.AI.Abstractions.Models;
using Elsa.Common.Models;
using Elsa.Extensions;
using Elsa.Workflows;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Management.Filters;
using Elsa.Workflows.Management.Models;
using Elsa.Workflows.Models;
using Elsa.Workflows.State;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.AI.IntegrationTests;
public class AIRuntimeGroundingToolTests
{
[Fact(DisplayName = "Runtime grounding tools return redacted incident evidence")]
public async Task RuntimeGroundingToolsReturnRedactedIncidentEvidence()
{
var instance = new WorkflowInstance
{
Id = "instance-1",
DefinitionId = "workflow-1",
DefinitionVersionId = "version-1",
Version = 1,
Status = WorkflowStatus.Finished,
SubStatus = WorkflowSubStatus.Faulted,
IncidentCount = 1,
WorkflowState = new WorkflowState
{
Incidents =
{
new ActivityIncident("activity-1", "node-1", "Elsa.Http.HttpEndpoint", "API key password leaked", null, DateTimeOffset.UtcNow)
},
Input = new Dictionary<string, object> { ["password"] = "secret" }
}
};
var services = new ServiceCollection();
services.AddAIHostServices();
services.AddSingleton<IWorkflowInstanceStore>(new TestWorkflowInstanceStore(instance));
using var provider = services.BuildServiceProvider();
var registry = provider.GetRequiredService<IAIToolRegistry>();
using var tool = await registry.FindAsync("incidents.search", new AIToolQuery { ActorId = "user-1" });
var result = await tool!.ExecuteAsync(new AIToolExecutionContext
{
ActorId = "user-1",
ConversationId = "conversation-1",
Arguments = new JsonObject { ["definitionId"] = "workflow-1" }
});
Assert.Equal(1, result.Data["returned"]!.GetValue<int>());
var incident = result.Data["items"]!.AsArray()[0]!.AsObject();
Assert.Equal("instance-1", incident["workflowInstanceId"]!.GetValue<string>());
Assert.DoesNotContain("secret", result.Data.ToJsonString(), StringComparison.OrdinalIgnoreCase);
}
private class TestWorkflowInstanceStore(params WorkflowInstance[] instances) : IWorkflowInstanceStore
{
private readonly List<WorkflowInstance> _instances = instances.ToList();
public ValueTask<WorkflowInstance?> FindAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(Apply(filter).FirstOrDefault());
public ValueTask<Page<WorkflowInstance>> FindManyAsync(WorkflowInstanceFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default)
{
var items = Apply(filter).ToList();
return ValueTask.FromResult(Page.Of<WorkflowInstance>(items, items.Count));
}
public ValueTask<Page<WorkflowInstance>> FindManyAsync<TOrderBy>(WorkflowInstanceFilter filter, PageArgs pageArgs, WorkflowInstanceOrder<TOrderBy> order, CancellationToken cancellationToken = default) =>
FindManyAsync(filter, pageArgs, cancellationToken);
public ValueTask<IEnumerable<WorkflowInstance>> FindManyAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) =>
ValueTask.FromResult<IEnumerable<WorkflowInstance>>(Apply(filter).ToList());
public ValueTask<IEnumerable<WorkflowInstance>> FindManyAsync<TOrderBy>(WorkflowInstanceFilter filter, WorkflowInstanceOrder<TOrderBy> order, CancellationToken cancellationToken = default) =>
FindManyAsync(filter, cancellationToken);
public ValueTask<long> CountAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) =>
ValueTask.FromResult((long)Apply(filter).Count());
public ValueTask<Page<WorkflowInstanceSummary>> SummarizeManyAsync(WorkflowInstanceFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default)
{
var items = Apply(filter).Select(WorkflowInstanceSummary.FromInstance).ToList();
return ValueTask.FromResult(Page.Of<WorkflowInstanceSummary>(items, items.Count));
}
public ValueTask<Page<WorkflowInstanceSummary>> SummarizeManyAsync<TOrderBy>(WorkflowInstanceFilter filter, PageArgs pageArgs, WorkflowInstanceOrder<TOrderBy> order, CancellationToken cancellationToken = default) =>
SummarizeManyAsync(filter, pageArgs, cancellationToken);
public ValueTask<IEnumerable<string>> FindManyIdsAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) =>
ValueTask.FromResult<IEnumerable<string>>(Apply(filter).Select(x => x.Id).ToList());
public ValueTask<Page<string>> FindManyIdsAsync(WorkflowInstanceFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default)
{
var ids = Apply(filter).Select(x => x.Id).ToList();
return ValueTask.FromResult(Page.Of<string>(ids, ids.Count));
}
public ValueTask<Page<string>> FindManyIdsAsync<TOrderBy>(WorkflowInstanceFilter filter, PageArgs pageArgs, WorkflowInstanceOrder<TOrderBy> order, CancellationToken cancellationToken = default) =>
FindManyIdsAsync(filter, pageArgs, cancellationToken);
public ValueTask<IEnumerable<WorkflowInstanceSummary>> SummarizeManyAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) =>
ValueTask.FromResult<IEnumerable<WorkflowInstanceSummary>>(Apply(filter).Select(WorkflowInstanceSummary.FromInstance).ToList());
public ValueTask<IEnumerable<WorkflowInstanceSummary>> SummarizeManyAsync<TOrder>(WorkflowInstanceFilter filter, WorkflowInstanceOrder<TOrder> order, CancellationToken cancellationToken = default) =>
SummarizeManyAsync(filter, cancellationToken);
public ValueTask SaveAsync(WorkflowInstance instance, CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
public ValueTask AddAsync(WorkflowInstance instance, CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
public ValueTask UpdateAsync(WorkflowInstance instance, CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
public ValueTask SaveManyAsync(IEnumerable<WorkflowInstance> instances, CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
public ValueTask<long> DeleteAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) => ValueTask.FromResult(0L);
public Task UpdateUpdatedTimestampAsync(string workflowInstanceId, DateTimeOffset value, CancellationToken cancellationToken = default) => Task.CompletedTask;
fix: persist Interrupted after drain force-cancel commits Cancelled (#8069) * fix: persist Interrupted after drain force-cancel commits Cancelled Deadline-breach force-cancel makes the runner persist Finished/Cancelled. #8059 then skipped every Finished row, so Interrupted never landed and Packages CI failed DeadlineBreachPersistsInterrupted. Treat Cancelled as interruptible and promote it to Running+Interrupted so recovery can requeue it, while still refusing naturally completed rows. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix: do not promote user cancellations to Interrupted on drain Gate Cancelled→Interrupted on instances that were not already Cancelled when drain snapshotted live cycles. Deadline-breach force-cancel still promotes the runner's Finished/Cancelled commit; ordinary client cancellations stay Cancelled and are not requeued. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix: confine Cancelled→Interrupted promote to Drain Restore TryMarkInterruptedAsync to refuse every Finished row by default (#8052). Drain PersistInterrupted alone may pass allowFinishedCancelled when the instance is Finished/Cancelled and in this drain's force-cancelled set. User cancellations stay cancelled. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * docs: document TryMarkInterruptedAsync parameters Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix: bound pre-cancel snapshot so stalled Find cannot block Cancel WaitAsync the instance-store snapshot under a short shutdown budget so a hang or ignored cancellation token cannot delay handle.Cancel(). Unknown pre-state is not treated as already Cancelled; observed user cancellations are still preserved. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * test: restore Fact on terminal-race drain persist skip Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix: exclude unknown snapshot rows from drain-induced promote A timed-out or failed pre-cancel Find no longer joins drainInduced. Only a successful read that is clearly not already Cancelled may be promoted. Cancel still proceeds without waiting on store latency. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix: give each pre-cancel snapshot its own timeout A shared 250ms overallSnapshotCts let a stalled first Find cancel later Finds before they started, so those instances were excluded from drainInduced and never persisted as Interrupted after Phase A Cancel. Each Find now uses an independent CTS linked only to the host token. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix: snapshot live instances concurrently before Phase A Cancel Independent per-find 250ms budgets kept recovery, but a serial foreach still delayed every handle.Cancel by up to N×250ms. Run those bounded Finds with Task.WhenAll so Cancel waits one timeout window, not N. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix: give each parallel snapshot Find its own DI scope Task.WhenAll was sharing one scoped IWorkflowInstanceStore. EF DbContext is not thread-safe; Phase C already persists sequentially for that reason. Each snapshot task now CreateScope()s its own store and disposes it. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-12 21:23:47 +00:00
public ValueTask<bool> TryMarkInterruptedAsync(string workflowInstanceId, CancellationToken cancellationToken = default, bool allowFinishedCancelled = false)
fix: stop requeuing Finished+Interrupted workflow instances (#8052) * fix: stop requeuing Finished+Interrupted workflow instances InterruptedRecoveryScanner now requires Status=Running, matching the sibling crash-recovery task. DrainOrchestrator skips already-terminal instances so a runner-clobber race cannot stamp Interrupted onto a Finished row. Fixes #8052. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix: conditionally mark Interrupted so drain cannot clobber Finished PersistInterruptedAsync no longer SaveAsync-es the Find snapshot. The store now applies Interrupted only when Status is still non-terminal (EF: ExecuteUpdate WHERE Status != Finished; memory: mutate the live row). A runner that commits Finished between read and write keeps its terminal state, so startup recovery cannot requeue completed work. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * test: disambiguate NSubstitute Returns for TryMarkInterruptedAsync Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix: make in-memory Interrupted mark atomic against completion Lock the memory-store check and mutations together, then abort if Status became Finished in-place so drain cannot record Interrupted on a completed instance. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> * fix: share memory-store lock between Interrupted mark and Save TryMarkInterruptedAsync now serializes with Save/Update/SaveMany so a runner's terminal persist cannot land between the non-terminal check and the Interrupted mutations. A finishing Save therefore cannot leave Finished+Interrupted. Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-12 06:05:43 +00:00
{
var instance = _instances.FirstOrDefault(x => x.Id == workflowInstanceId);
if (instance is null || instance.Status == WorkflowStatus.Finished)
return ValueTask.FromResult(false);
instance.SubStatus = WorkflowSubStatus.Interrupted;
instance.IsExecuting = false;
return ValueTask.FromResult(true);
}
private IEnumerable<WorkflowInstance> Apply(WorkflowInstanceFilter filter)
{
var query = _instances.AsEnumerable();
if (!string.IsNullOrWhiteSpace(filter.Id))
query = query.Where(x => x.Id == filter.Id);
if (!string.IsNullOrWhiteSpace(filter.DefinitionId))
query = query.Where(x => x.DefinitionId == filter.DefinitionId);
if (filter.HasIncidents != null)
query = query.Where(x => filter.HasIncidents == true ? x.IncidentCount > 0 : x.IncidentCount == 0);
return query;
}
}
}