mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-15 14:13:47 +00:00
Locked changes to a chat while an answer is being written (#1058)
This commit is contained in:
1 parent
88d1f4d96a
commit
fbb65fd72d
19 files changed
+841
-59
No files matched your search
@@ -0,0 +1,254 @@
|
||||
using AIStudio.Chat;
|
||||
|
||||
namespace AIStudio.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the messages of a chat stay as they are while an answer is being written for it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// While an answer is being written, the AI job holds the very same thread as the chat. A message the
|
||||
/// user removes then does not come back, but the chat waits for an answer nobody sees, and the job
|
||||
/// stores the conversation while it changes. So every change to a message is turned down until the
|
||||
/// answer is complete or stopped, and works as before once it is.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ChatBlockActionsTests
|
||||
{
|
||||
private static readonly DateTimeOffset START = new(2026, 10, 13, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Test]
|
||||
public void RemovingTheAnswerBeingWrittenLeavesTheChatAsItIs()
|
||||
{
|
||||
var question = Block(ChatRole.USER, "Question", 1);
|
||||
var answer = Block(ChatRole.AI, "Answer being", 2);
|
||||
var chat = Chat(question, answer);
|
||||
|
||||
var removed = WhileWriting(chat).TryRemove(chat, answer.Content!);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(removed, Is.False);
|
||||
Assert.That(chat.Blocks.Select(x => x.Content), Is.EqualTo(new[] { question.Content, answer.Content }), "The answer stays, since the job goes on writing into it.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OlderMessagesStayWhileAnAnswerIsBeingWritten()
|
||||
{
|
||||
var firstQuestion = Block(ChatRole.USER, "First question", 1);
|
||||
var firstAnswer = Block(ChatRole.AI, "First answer", 2);
|
||||
var chat = Chat(firstQuestion, firstAnswer, Block(ChatRole.USER, "Second question", 3), Block(ChatRole.AI, "Second answer being", 4));
|
||||
var blocks = chat.Blocks.ToList();
|
||||
var actions = WhileWriting(chat);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(actions.TryRemove(chat, firstQuestion.Content!), Is.False);
|
||||
Assert.That(actions.TryRemove(chat, firstAnswer.Content!), Is.False);
|
||||
Assert.That(actions.TryRemoveForRegenerate(chat, firstAnswer.Content!), Is.False);
|
||||
Assert.That(chat.Blocks, Is.EqualTo(blocks), "The job stores the conversation meanwhile, so no message may change, not even an older one.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EditingTheQuestionChangesNeitherTheChatNorTheDraft()
|
||||
{
|
||||
var question = Block(ChatRole.USER, "Question", 1);
|
||||
var answer = Block(ChatRole.AI, "Answer being", 2);
|
||||
var chat = Chat(question, answer);
|
||||
var actions = WhileWriting(chat);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(actions.TryEditLastUserBlock(chat, question.Content!), Is.False);
|
||||
Assert.That(actions.TryEditLastBlock(chat, question.Content!), Is.False);
|
||||
Assert.That(chat.Blocks, Has.Count.EqualTo(2), "Both the question and the answer being written stay.");
|
||||
Assert.That(chat.Draft.IsEmpty, Is.True, "The question does not become the draft, since it could not be sent anyway.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RegeneratingKeepsTheAnswerBeingWritten()
|
||||
{
|
||||
var answer = Block(ChatRole.AI, "Answer being", 2);
|
||||
var chat = Chat(Block(ChatRole.USER, "Question", 1), answer);
|
||||
|
||||
var removed = WhileWriting(chat).TryRemoveForRegenerate(chat, answer.Content!);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(removed, Is.False, "Regenerating would leave a second answer next to the one being written.");
|
||||
Assert.That(chat.Blocks, Has.Count.EqualTo(2));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RollingBackIsTurnedDownWhileAnAnswerIsBeingWritten()
|
||||
{
|
||||
var firstAnswer = Block(ChatRole.AI, "First answer", 2);
|
||||
var chat = Chat(Block(ChatRole.USER, "First question", 1), firstAnswer, Block(ChatRole.USER, "Second question", 3), Block(ChatRole.AI, "Second answer being", 4));
|
||||
|
||||
var rolledBack = WhileWriting(chat).TryRollBack(chat, firstAnswer.Content!);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(rolledBack, Is.False);
|
||||
Assert.That(chat.Blocks, Has.Count.EqualTo(4));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RemovingAMessageWorksOnceTheAnswerIsComplete()
|
||||
{
|
||||
var question = Block(ChatRole.USER, "Question", 1);
|
||||
var answer = Block(ChatRole.AI, "Answer", 2);
|
||||
var chat = Chat(question, answer);
|
||||
|
||||
var removed = Done().TryRemove(chat, answer.Content!);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(removed, Is.True);
|
||||
Assert.That(chat.Blocks.Select(x => x.Content), Is.EqualTo(new[] { question.Content }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RegeneratingWorksOnceTheAnswerIsComplete()
|
||||
{
|
||||
var question = Block(ChatRole.USER, "Question", 1);
|
||||
var answer = Block(ChatRole.AI, "Answer", 2);
|
||||
var chat = Chat(question, answer);
|
||||
|
||||
var removed = Done().TryRemoveForRegenerate(chat, answer.Content!);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(removed, Is.True);
|
||||
Assert.That(chat.Blocks.Select(x => x.Content), Is.EqualTo(new[] { question.Content }), "The question stays, to be answered anew.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RollingBackWorksOnceTheAnswerIsComplete()
|
||||
{
|
||||
var firstQuestion = Block(ChatRole.USER, "First question", 1);
|
||||
var firstAnswer = Block(ChatRole.AI, "First answer", 2);
|
||||
var chat = Chat(firstQuestion, firstAnswer, Block(ChatRole.USER, "Second question", 3), Block(ChatRole.AI, "Second answer", 4));
|
||||
|
||||
var rolledBack = Done().TryRollBack(chat, firstAnswer.Content!);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(rolledBack, Is.True);
|
||||
Assert.That(chat.Blocks.Select(x => x.Content), Is.EqualTo(new[] { firstQuestion.Content, firstAnswer.Content }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EditingTheQuestionWorksOnceTheAnswerIsComplete()
|
||||
{
|
||||
var question = Block(ChatRole.USER, "Question", 1);
|
||||
var chat = Chat(question, Block(ChatRole.AI, "Answer", 2));
|
||||
|
||||
var edited = Done().TryEditLastUserBlock(chat, question.Content!);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(edited, Is.True);
|
||||
Assert.That(chat.Blocks, Is.Empty, "The question goes back into the composer, and the answer to it goes.");
|
||||
Assert.That(chat.Draft.Text, Is.EqualTo("Question"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EditingAQuestionWithoutAnswerWorksOnceTheAnswerIsGone()
|
||||
{
|
||||
var question = Block(ChatRole.USER, "Question", 1);
|
||||
var chat = Chat(question);
|
||||
|
||||
var edited = Done().TryEditLastBlock(chat, question.Content!);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(edited, Is.True);
|
||||
Assert.That(chat.Blocks, Is.Empty);
|
||||
Assert.That(chat.Draft.Text, Is.EqualTo("Question"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EditingAQuestionOfAnotherChatKeepsTheAnswer()
|
||||
{
|
||||
var answer = Block(ChatRole.AI, "Answer", 2);
|
||||
var chat = Chat(Block(ChatRole.USER, "Question", 1), answer);
|
||||
|
||||
var edited = Done().TryEditLastUserBlock(chat, new ContentText { Text = "Question of another chat" });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(edited, Is.False);
|
||||
Assert.That(chat.Blocks, Has.Count.EqualTo(2), "Removing the last answer only makes sense together with the question to it.");
|
||||
Assert.That(chat.Draft.IsEmpty, Is.True);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnAnswerBeingWrittenInAnotherChatLocksNothingHere()
|
||||
{
|
||||
var answer = Block(ChatRole.AI, "Answer", 2);
|
||||
var chat = Chat(Block(ChatRole.USER, "Question", 1), answer);
|
||||
var otherChatId = Guid.NewGuid();
|
||||
var askedChatIds = new List<Guid>();
|
||||
var actions = new ChatBlockActions(chatId =>
|
||||
{
|
||||
askedChatIds.Add(chatId);
|
||||
return chatId == otherChatId;
|
||||
});
|
||||
|
||||
var removed = actions.TryRemove(chat, answer.Content!);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(removed, Is.True);
|
||||
Assert.That(askedChatIds, Is.EqualTo(new[] { chat.ChatId }), "Only the chat being changed decides.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WithoutAChatNothingIsAllowed()
|
||||
{
|
||||
var actions = Done();
|
||||
var content = new ContentText { Text = "Question" };
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(actions.AreAllowed(null), Is.False);
|
||||
Assert.That(actions.TryRemove(null, content), Is.False);
|
||||
Assert.That(actions.TryRemoveForRegenerate(null, content), Is.False);
|
||||
Assert.That(actions.TryRollBack(null, content), Is.False);
|
||||
Assert.That(actions.TryEditLastUserBlock(null, content), Is.False);
|
||||
Assert.That(actions.TryEditLastBlock(null, content), Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The actions while an answer is being written for the given chat.
|
||||
/// </summary>
|
||||
private static ChatBlockActions WhileWriting(ChatThread chat) => new(chatId => chatId == chat.ChatId);
|
||||
|
||||
/// <summary>
|
||||
/// The actions while no answer is being written for any chat.
|
||||
/// </summary>
|
||||
private static ChatBlockActions Done() => new(_ => false);
|
||||
|
||||
private static ChatThread Chat(params ContentBlock[] blocks) => new() { ChatId = Guid.NewGuid(), Blocks = [..blocks] };
|
||||
|
||||
private static ContentBlock Block(ChatRole role, string text, int minute) => new()
|
||||
{
|
||||
Time = START.AddMinutes(minute),
|
||||
ContentType = ContentType.TEXT,
|
||||
Content = new ContentText { Text = text },
|
||||
Role = role,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools;
|
||||
|
||||
namespace AIStudio.Tests.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Checks when a workspace may be deleted together with its chats.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The work on a busy chat, an answer or a transcript, stores the chat once more when it is done. Had
|
||||
/// its workspace been deleted meanwhile, that brings the workspace back as an unnamed one. A busy chat
|
||||
/// therefore keeps the whole workspace, while the chats of other workspaces have no say in it.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
[NonParallelizable]
|
||||
public sealed class WorkspaceDeleteTests
|
||||
{
|
||||
private string? previousDataDirectory;
|
||||
private string testDirectory = string.Empty;
|
||||
|
||||
[SetUp]
|
||||
public async Task PrepareDataDirectory()
|
||||
{
|
||||
//
|
||||
// The data directory is static state of the whole application, which is why this fixture
|
||||
// does not run alongside others. The workspace behaviour keeps the roots of the workspaces
|
||||
// and of the temporary chats fixed, as the app never changes its data directory, so it gets
|
||||
// told about the one of this test separately. The cache is static as well, so it starts
|
||||
// over from what the new data directory holds: nothing.
|
||||
//
|
||||
this.previousDataDirectory = SettingsManager.DataDirectory;
|
||||
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-workspace-delete-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(this.testDirectory);
|
||||
SettingsManager.DataDirectory = this.testDirectory;
|
||||
WorkspaceBehaviour.TestDataDirectory = this.testDirectory;
|
||||
|
||||
await WorkspaceBehaviour.ForceReloadWorkspaceTreeAsync();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void RemoveDataDirectory()
|
||||
{
|
||||
SettingsManager.DataDirectory = this.previousDataDirectory;
|
||||
WorkspaceBehaviour.TestDataDirectory = null;
|
||||
|
||||
// Whoever reads the cache next must not get the workspaces 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.
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ABusyChatKeepsTheWholeWorkspace()
|
||||
{
|
||||
var workspaceId = await CreateWorkspaceAsync("Projects");
|
||||
var busyChat = await StoreChatAsync(workspaceId, "Meeting notes");
|
||||
var idleChat = await StoreChatAsync(workspaceId, "Old notes");
|
||||
|
||||
var deleted = await WorkspaceBehaviour.DeleteWorkspaceAsync(workspaceId, chatId => chatId == busyChat.ChatId);
|
||||
|
||||
var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(deleted, Is.False, "The caller has to learn that the workspace is still there.");
|
||||
Assert.That(WorkspaceBehaviour.IsChatExisting(new LoadChat(workspaceId, busyChat.ChatId)), Is.True, "The answer or the transcript still needs the chat it is made for.");
|
||||
Assert.That(WorkspaceBehaviour.IsChatExisting(new LoadChat(workspaceId, idleChat.ChatId)), Is.True, "The user asked for the whole workspace to go, not for some of its chats.");
|
||||
Assert.That(snapshot.Workspaces.Select(workspace => workspace.WorkspaceId), Does.Contain(workspaceId), "The workspace list keeps showing the workspace, which still exists.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AWorkspaceWithoutBusyChatsIsGone()
|
||||
{
|
||||
var workspaceId = await CreateWorkspaceAsync("Projects");
|
||||
var chat = await StoreChatAsync(workspaceId, "Meeting notes");
|
||||
|
||||
var deleted = await WorkspaceBehaviour.DeleteWorkspaceAsync(workspaceId, _ => false);
|
||||
|
||||
var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(deleted, Is.True);
|
||||
Assert.That(WorkspaceBehaviour.IsWorkspaceExisting(workspaceId), Is.False, "The workspace has to be gone from the disk, its chats included.");
|
||||
Assert.That(WorkspaceBehaviour.IsChatExisting(new LoadChat(workspaceId, chat.ChatId)), Is.False);
|
||||
Assert.That(snapshot.Workspaces.Select(workspace => workspace.WorkspaceId), Does.Not.Contain(workspaceId), "The workspace list must not show a workspace which is gone.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ABusyChatInAnotherWorkspaceDoesNotKeepTheWorkspace()
|
||||
{
|
||||
var workspaceId = await CreateWorkspaceAsync("Projects");
|
||||
await StoreChatAsync(workspaceId, "Meeting notes");
|
||||
var otherId = await CreateWorkspaceAsync("Archive");
|
||||
var busyChat = await StoreChatAsync(otherId, "Old notes");
|
||||
|
||||
var deleted = await WorkspaceBehaviour.DeleteWorkspaceAsync(workspaceId, chatId => chatId == busyChat.ChatId);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(deleted, Is.True, "Only the chats of the workspace being deleted count.");
|
||||
Assert.That(WorkspaceBehaviour.IsWorkspaceExisting(workspaceId), Is.False);
|
||||
Assert.That(WorkspaceBehaviour.IsChatExisting(new LoadChat(otherId, busyChat.ChatId)), Is.True, "The other workspace has nothing to do with it and stays as it is.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AWorkspaceWhichIsNotThereCountsAsDeleted()
|
||||
{
|
||||
var deleted = await WorkspaceBehaviour.DeleteWorkspaceAsync(Guid.NewGuid(), _ => false);
|
||||
|
||||
Assert.That(deleted, Is.True, "There is nothing left to delete, so the caller may go on.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheDisappearingChatsAlwaysExist()
|
||||
{
|
||||
// The temporary chats belong to no workspace, so a new chat among them asks with the empty ID, and it has to be stored:
|
||||
Assert.That(WorkspaceBehaviour.IsWorkspaceExisting(Guid.Empty), Is.True);
|
||||
}
|
||||
|
||||
private static async Task<Guid> CreateWorkspaceAsync(string name)
|
||||
{
|
||||
var result = await WorkspaceBehaviour.TryCreateWorkspaceAsync(name);
|
||||
Assert.That(result.Success, Is.True, $"The workspace '{name}' could not be created.");
|
||||
return result.Workspace.WorkspaceId;
|
||||
}
|
||||
|
||||
private static async Task<ChatThread> StoreChatAsync(Guid workspaceId, string name)
|
||||
{
|
||||
var chat = new ChatThread { WorkspaceId = workspaceId, ChatId = Guid.NewGuid(), Name = name };
|
||||
Assert.That(await WorkspaceBehaviour.StoreChatAsync(chat), Is.True, $"The chat '{name}' could not be stored.");
|
||||
return chat;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user