mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 21:47:22 +00:00
Fixed chats being stored when chats are stored manually or workspaces are disabled (#1145)
This commit is contained in:
1 parent
9532664a25
commit
64dc621a7d
12 files changed
+415
-71
No files matched your search
@@ -0,0 +1,202 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools;
|
||||
using AIStudio.Tools.AIJobs;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.Tools.AIJobs;
|
||||
|
||||
/// <summary>
|
||||
/// Checks when the AI job of a chat stores that chat.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The job stores the chat while the answer is written, so that a chat being answered survives when the
|
||||
/// user goes elsewhere. That is what storing chats automatically means. Whoever stores chats manually
|
||||
/// decides with the save button when a chat reaches the disk, and whoever disabled the workspaces wants
|
||||
/// no chat on the disk at all. The job of such a chat must leave the disk alone: neither a new chat may
|
||||
/// appear there, nor may a saved one take on what the user did not save.<br/><br/>
|
||||
///
|
||||
/// The jobs here run without a provider, so they end right away, the way a failed request ends. That
|
||||
/// is enough: every job ends through the same last checkpoint, and the first one is taken before the
|
||||
/// provider is asked anything.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
[NonParallelizable]
|
||||
public sealed class AIJobServiceChatStorageTests
|
||||
{
|
||||
private static readonly TimeSpan WAIT_LIMIT = TimeSpan.FromSeconds(10);
|
||||
|
||||
private string? previousDataDirectory;
|
||||
private string testDirectory = string.Empty;
|
||||
private readonly List<IMessageBusReceiver> registeredReceivers = [];
|
||||
|
||||
[SetUp]
|
||||
public async Task PrepareDataDirectory()
|
||||
{
|
||||
//
|
||||
// The data directory, the workspace cache, and the message bus are static state of the whole
|
||||
// application, which is why this fixture does not run alongside others. See WorkspaceChatStoreTests
|
||||
// for why the workspace behaviour gets told about the data directory separately.
|
||||
//
|
||||
this.previousDataDirectory = SettingsManager.DataDirectory;
|
||||
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-ai-job-storage-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(this.testDirectory);
|
||||
SettingsManager.DataDirectory = this.testDirectory;
|
||||
WorkspaceBehaviour.TestDataDirectory = this.testDirectory;
|
||||
|
||||
await WorkspaceBehaviour.ForceReloadWorkspaceTreeAsync();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void RemoveDataDirectory()
|
||||
{
|
||||
foreach (var receiver in this.registeredReceivers)
|
||||
MessageBus.INSTANCE.Unregister(receiver);
|
||||
|
||||
this.registeredReceivers.Clear();
|
||||
SettingsManager.DataDirectory = this.previousDataDirectory;
|
||||
WorkspaceBehaviour.TestDataDirectory = null;
|
||||
|
||||
// Whoever reads the cache next must not get the chats of this test:
|
||||
WorkspaceBehaviour.InvalidateWorkspaceTreeCache();
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(this.testDirectory, true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A temporary directory we could not remove says nothing about the code under test.
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase(WorkspaceStorageBehavior.DISABLE_WORKSPACES)]
|
||||
[TestCase(WorkspaceStorageBehavior.STORE_CHATS_MANUALLY)]
|
||||
public async Task ANewChatStaysOffTheDiskWhileChatsAreNotStoredAutomatically(WorkspaceStorageBehavior storageBehavior)
|
||||
{
|
||||
var chat = NewChat("What is the capital of France?");
|
||||
|
||||
await this.AnswerAsync(chat, storageBehavior);
|
||||
|
||||
// Reading the workspace tree creates the directory of the temporary chats, so only what it holds tells:
|
||||
var temporaryChats = Path.Combine(this.testDirectory, "tempChats");
|
||||
var temporaryChatEntries = Directory.Exists(temporaryChats) ? Directory.EnumerateFileSystemEntries(temporaryChats).ToList() : [];
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(WorkspaceBehaviour.IsChatExisting(new LoadChat(chat.WorkspaceId, chat.ChatId)), Is.False, "Sending a message is no reason to store a chat the user did not save, or which they want kept nowhere.");
|
||||
Assert.That(temporaryChatEntries, Is.Empty, "A chat outside the workspaces would end up among the temporary chats, where nobody sees it while the workspaces are disabled.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AChatSavedByHandKeepsItsSavedStateUntilTheNextSave()
|
||||
{
|
||||
var chat = NewChat("What is the capital of France?");
|
||||
Assert.That(await WorkspaceBehaviour.StoreChatAsync(chat), Is.True, "The chat could not be saved.");
|
||||
|
||||
// A message sent after saving, which the user did not save yet:
|
||||
AddUserMessage(chat, "And the one of Italy?");
|
||||
await this.AnswerAsync(chat, WorkspaceStorageBehavior.STORE_CHATS_MANUALLY);
|
||||
|
||||
var stored = await ReadStoredChatAsync(chat);
|
||||
Assert.That(stored.Blocks, Has.Count.EqualTo(1), "Only saving puts the new message on the disk; until then, the chat on the disk is the one the user saved.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AnAnsweredChatIsStoredWhileChatsAreStoredAutomatically()
|
||||
{
|
||||
var chat = NewChat("What is the capital of France?");
|
||||
|
||||
await this.AnswerAsync(chat, WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY);
|
||||
|
||||
Assert.That(WorkspaceBehaviour.IsChatExisting(new LoadChat(chat.WorkspaceId, chat.ChatId)), Is.True, "Storing chats automatically means the chat reaches the disk with its first message.");
|
||||
var stored = await ReadStoredChatAsync(chat);
|
||||
Assert.That(stored.Blocks.Select(block => block.Content).OfType<ContentText>().Select(text => text.Text), Does.Contain("What is the capital of France?"), "The stored chat has to hold the message which was sent.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lets the AI job answer the given chat, like sending a message does, and waits until the job is done.
|
||||
/// </summary>
|
||||
/// <param name="chat">The chat, with the message to answer as its last block.</param>
|
||||
/// <param name="storageBehavior">How the user wants their chats stored.</param>
|
||||
private async Task AnswerAsync(ChatThread chat, WorkspaceStorageBehavior storageBehavior)
|
||||
{
|
||||
var settingsManager = new SettingsManager(NullLogger<SettingsManager>.Instance, null!);
|
||||
settingsManager.ConfigurationData.Workspace.StorageBehavior = storageBehavior;
|
||||
|
||||
var aiText = new ContentText { InitialRemoteWait = true };
|
||||
chat.Blocks.Add(new ContentBlock
|
||||
{
|
||||
Time = DateTimeOffset.Now,
|
||||
ContentType = ContentType.TEXT,
|
||||
Role = ChatRole.AI,
|
||||
Content = aiText,
|
||||
});
|
||||
|
||||
// The bus announces the end of a job only after its last checkpoint, so nothing is stored afterward:
|
||||
var finishedJob = new FinishedJobReceiver(chat.ChatId);
|
||||
MessageBus.INSTANCE.RegisterComponent(finishedJob);
|
||||
MessageBus.INSTANCE.ApplyFilters(finishedJob, [], [Event.AI_JOB_FINISHED]);
|
||||
this.registeredReceivers.Add(finishedJob);
|
||||
|
||||
var aiJobService = new AIJobService(settingsManager, MessageBus.INSTANCE, NullLogger<AIJobService>.Instance);
|
||||
var snapshot = await aiJobService.TryStartChatGenerationAsync(new ChatGenerationRequest
|
||||
{
|
||||
ChatThread = chat,
|
||||
AIText = aiText,
|
||||
LastUserPrompt = chat.Blocks.Last(block => block.Role is ChatRole.USER).Content,
|
||||
ProviderSettings = AIStudio.Settings.Provider.NONE,
|
||||
});
|
||||
|
||||
Assert.That(snapshot, Is.Not.Null, "The AI job did not start.");
|
||||
await finishedJob.Finished.WaitAsync(WAIT_LIMIT);
|
||||
}
|
||||
|
||||
private static ChatThread NewChat(string message)
|
||||
{
|
||||
var chat = new ChatThread { WorkspaceId = Guid.Empty, ChatId = Guid.NewGuid(), Name = "Capitals" };
|
||||
AddUserMessage(chat, message);
|
||||
return chat;
|
||||
}
|
||||
|
||||
private static void AddUserMessage(ChatThread chat, string message) => chat.Blocks.Add(new ContentBlock
|
||||
{
|
||||
Time = DateTimeOffset.Now,
|
||||
ContentType = ContentType.TEXT,
|
||||
Role = ChatRole.USER,
|
||||
Content = new ContentText { Text = message },
|
||||
});
|
||||
|
||||
private static async Task<ChatThread> ReadStoredChatAsync(ChatThread chat)
|
||||
{
|
||||
var chatPath = Path.Combine(WorkspaceBehaviour.GetChatDirectory(chat.WorkspaceId, chat.ChatId), "thread.json");
|
||||
return JsonSerializer.Deserialize<ChatThread>(await File.ReadAllTextAsync(chatPath), WorkspaceBehaviour.JSON_OPTIONS)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Learns from the message bus when the job of one chat is done.
|
||||
/// </summary>
|
||||
/// <param name="chatId">The chat whose job to wait for.</param>
|
||||
private sealed class FinishedJobReceiver(Guid chatId) : IMessageBusReceiver
|
||||
{
|
||||
private readonly TaskCompletionSource finished = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public Task Finished => this.finished.Task;
|
||||
|
||||
public Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data)
|
||||
{
|
||||
if (triggeredEvent is Event.AI_JOB_FINISHED && data is AIJobSnapshot snapshot && snapshot.SubjectId == chatId)
|
||||
this.finished.TrySetResult();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<MessageBusAnswer<TResult>> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) => Task.FromResult(MessageBusAnswer<TResult>.NONE);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user