Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,12 @@
<PackageVersion Include="coverlet.collector" Version="6.0.4" PrivateAssets="All"/>
<PackageVersion Include="coverlet.msbuild" Version="6.0.4" PrivateAssets="All"/>
<PackageVersion Include="Cronos" Version="0.11.1"/>
<PackageVersion Include="CShells" Version="0.0.24-preview.132"/>
<PackageVersion Include="CShells.Abstractions" Version="0.0.24-preview.132"/>
<PackageVersion Include="CShells.AspNetCore" Version="0.0.24-preview.132"/>
<PackageVersion Include="CShells.AspNetCore.Abstractions" Version="0.0.24-preview.132"/>
<PackageVersion Include="CShells.FastEndpoints" Version="0.0.24-preview.132"/>
<PackageVersion Include="CShells.FastEndpoints.Abstractions" Version="0.0.24-preview.132"/>
<PackageVersion Include="CShells" Version="0.0.24"/>
<PackageVersion Include="CShells.Abstractions" Version="0.0.24"/>
<PackageVersion Include="CShells.AspNetCore" Version="0.0.24"/>
<PackageVersion Include="CShells.AspNetCore.Abstractions" Version="0.0.24"/>
<PackageVersion Include="CShells.FastEndpoints" Version="0.0.24"/>
<PackageVersion Include="CShells.FastEndpoints.Abstractions" Version="0.0.24"/>
Comment on lines +109 to +114
<PackageVersion Include="Elsa.PackageManifest.Generator" Version="0.0.1-preview.43"/>
<PackageVersion Include="Datadog.Trace.Bundle" Version="3.32.0"/>
<PackageVersion Include="DistributedLock" Version="2.7.1"/>
Expand Down
2 changes: 2 additions & 0 deletions NuGet.Config
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
<packageSourceMapping>
<packageSource key="NuGet official package source">
<package pattern="*" />
<package pattern="CShells" />
<package pattern="CShells.*" />
</packageSource>
Comment on lines 12 to 16
<packageSource key="cshells-feedz">
<package pattern="CShells" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ private void PruneExpired()
private bool IsExpired(AIConversation conversation)
{
if (conversation.RetentionMode == AIRetentionMode.Ephemeral)
return conversation.Status is AIConversationStatus.Completed or AIConversationStatus.Failed;
return conversation.Status is AIConversationStatus.Completed or AIConversationStatus.Failed or AIConversationStatus.Expired;

Comment thread
sfmskywalker marked this conversation as resolved.
if (conversation.RetentionMode == AIRetentionMode.Durable)
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ public static async ValueTask<int> DeleteExpiredAsync(AIDbContext dbContext, Dat
var configuredRetentionMode = AIRetentionMode.Configured.ToString();
var completedStatus = AIConversationStatus.Completed.ToString();
var failedStatus = AIConversationStatus.Failed.ToString();
var expiredStatus = AIConversationStatus.Expired.ToString();

var deletedEphemeral = await dbContext.Conversations
.Where(x => x.RetentionMode == ephemeralRetentionMode && (x.Status == completedStatus || x.Status == failedStatus))
.Where(x => x.RetentionMode == ephemeralRetentionMode && (x.Status == completedStatus || x.Status == failedStatus || x.Status == expiredStatus))
.ExecuteDeleteAsync(cancellationToken);

var deletedConfigured = await DeleteExpiredConfiguredAsync(dbContext, configuredRetentionMode, now, cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ private static (List<AIMessage> Messages, string Json) ShrinkMessagesToByteLimit
private static bool IsExpired(AIConversation conversation)
{
if (conversation.RetentionMode == AIRetentionMode.Ephemeral)
return conversation.Status is AIConversationStatus.Completed or AIConversationStatus.Failed;
return conversation.Status is AIConversationStatus.Completed or AIConversationStatus.Failed or AIConversationStatus.Expired;

if (conversation.RetentionMode == AIRetentionMode.Durable)
return false;
Expand Down
169 changes: 0 additions & 169 deletions test/unit/Elsa.AI.Host.UnitTests/AIRegistrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,175 +157,6 @@ public async Task AIHostValidatesScopedContextProvidersFromStartupScope()
await validator.StartAsync(CancellationToken.None);
}

[Fact(DisplayName = "In-memory conversation store evicts expired conversations")]
public async Task InMemoryConversationStoreEvictsExpiredConversations()
{
var store = new InMemoryAIConversationStore();

await store.SaveAsync(new AIConversation
{
Id = "conversation-1",
UserId = "user-1",
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-10),
UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-10),
RetentionExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-5)
});

var result = await store.FindAsync("conversation-1");

Assert.Null(result);
}

[Fact(DisplayName = "In-memory conversation store retains ephemeral conversations in process")]
public async Task InMemoryConversationStoreRetainsEphemeralConversationsInProcess()
{
var store = new InMemoryAIConversationStore();

await store.SaveAsync(new AIConversation
{
Id = "conversation-1",
UserId = "user-1",
RetentionMode = AIRetentionMode.Ephemeral,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});

var result = await store.FindAsync("conversation-1");

Assert.NotNull(result);
}

[Fact(DisplayName = "In-memory conversation store prunes completed ephemeral conversations")]
public async Task InMemoryConversationStorePrunesCompletedEphemeralConversations()
{
var store = new InMemoryAIConversationStore();

await store.SaveAsync(new AIConversation
{
Id = "conversation-1",
UserId = "user-1",
Status = AIConversationStatus.Completed,
RetentionMode = AIRetentionMode.Ephemeral,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});

var result = await store.FindAsync("conversation-1");

Assert.Null(result);
}

[Fact(DisplayName = "In-memory conversation store rejects cross-tenant overwrites")]
public async Task InMemoryConversationStoreRejectsCrossTenantOverwrites()
{
var store = new InMemoryAIConversationStore();
await store.SaveAsync(new AIConversation
{
Id = "conversation-1",
TenantId = "tenant-1",
UserId = "user-1",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});

var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () => await store.SaveAsync(new AIConversation
{
Id = "conversation-1",
TenantId = "tenant-2",
UserId = "user-1",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
}));

Assert.Equal("Cannot overwrite an AI conversation that belongs to another tenant.", exception.Message);
}

[Fact(DisplayName = "In-memory conversation store treats null and empty tenant IDs as default tenant")]
public async Task InMemoryConversationStoreTreatsNullAndEmptyTenantIdsAsDefaultTenant()
{
var store = new InMemoryAIConversationStore();
await store.SaveAsync(new AIConversation
{
Id = "conversation-1",
TenantId = null,
UserId = "user-1",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});

await store.SaveAsync(new AIConversation
{
Id = "conversation-1",
TenantId = "",
UserId = "user-1",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});

var conversation = await store.FindAsync("conversation-1");

Assert.NotNull(conversation);
Assert.Equal("", conversation.TenantId);
}

[Fact(DisplayName = "In-memory conversation store treats conversation IDs case-insensitively")]
public async Task InMemoryConversationStoreTreatsConversationIdsCaseInsensitively()
{
var store = new InMemoryAIConversationStore();
await store.SaveAsync(new AIConversation
{
Id = "Conversation-1",
UserId = "user-1",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});

var conversation = await store.FindAsync("conversation-1");

Assert.NotNull(conversation);
Assert.Equal("Conversation-1", conversation.Id);
}

[Fact(DisplayName = "In-memory conversation store rejects cross-user overwrites")]
public async Task InMemoryConversationStoreRejectsCrossUserOverwrites()
{
var store = new InMemoryAIConversationStore();
await store.SaveAsync(new AIConversation
{
Id = "conversation-1",
TenantId = "tenant-1",
UserId = "user-1",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});

var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () => await store.SaveAsync(new AIConversation
{
Id = "conversation-1",
TenantId = "tenant-1",
UserId = "user-2",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
}));

Assert.Equal("Cannot overwrite an AI conversation that belongs to another user.", exception.Message);
}

[Fact(DisplayName = "In-memory conversation store validates required conversation fields")]
public async Task InMemoryConversationStoreValidatesRequiredConversationFields()
{
var store = new InMemoryAIConversationStore();
var conversation = new AIConversation
{
Id = "conversation-invalid"
};

var exception = await Assert.ThrowsAsync<ArgumentException>(async () => await store.SaveAsync(conversation));

Assert.Equal("conversation", exception.ParamName);
Assert.Equal("A conversation user ID is required. (Parameter 'conversation')", exception.Message);
}

private class ScopedAuditHandler : IAIAuditEventHandler
{
public static int RecordedCount { get; set; }
Expand Down
Loading
Loading