mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-13 03:13:48 +00:00
Kept the unsent message with its chat (#1049)
This commit is contained in:
1 parent
1c13300988
commit
468d28aa94
18 files changed
+1333
-240
No files matched your search
@@ -0,0 +1,192 @@
|
||||
using AIStudio.Chat;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Checks when a chat gets stored with the message the user has not sent yet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Storing writes the whole conversation, so it waits until the user paused, and it happens once more
|
||||
/// when the user leaves the chat. A chat being answered is stored by its AI job instead, and a chat which
|
||||
/// is not on the disk is left to whoever creates it: when it is gone, it was deleted.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ChatDraftAutosaveTests
|
||||
{
|
||||
private static readonly TimeSpan SHORT_DELAY = TimeSpan.FromMilliseconds(50);
|
||||
private static readonly TimeSpan LONG_DELAY = TimeSpan.FromHours(1);
|
||||
private static readonly TimeSpan WAIT_LIMIT = TimeSpan.FromSeconds(10);
|
||||
|
||||
[Test]
|
||||
public async Task ManyChangesInARowAreStoredOnce()
|
||||
{
|
||||
var storage = new FakeStorage();
|
||||
await using var autosave = storage.CreateAutosave(SHORT_DELAY);
|
||||
var chat = new ChatThread { ChatId = Guid.NewGuid() };
|
||||
|
||||
for (var keystroke = 0; keystroke < 5; keystroke++)
|
||||
autosave.Schedule(chat);
|
||||
|
||||
await storage.WaitForStoreAsync();
|
||||
await Task.Delay(SHORT_DELAY * 4);
|
||||
|
||||
Assert.That(storage.StoredChats, Has.Count.EqualTo(1), "Every change starts the waiting anew, so typing a word writes the chat once, not once per letter.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LeavingTheChatStoresItRightAway()
|
||||
{
|
||||
var storage = new FakeStorage();
|
||||
await using var autosave = storage.CreateAutosave(LONG_DELAY);
|
||||
var chat = new ChatThread { ChatId = Guid.NewGuid() };
|
||||
|
||||
autosave.Schedule(chat);
|
||||
await autosave.FlushAsync();
|
||||
|
||||
Assert.That(storage.StoredChats, Is.EqualTo(new[] { chat }), "The chat being left has to be on the disk before the next one opens.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AChatBeingAnsweredIsStoredOnceTheAnswerIsDone()
|
||||
{
|
||||
var storage = new FakeStorage { IsBusy = true };
|
||||
await using var autosave = storage.CreateAutosave(SHORT_DELAY);
|
||||
var chat = new ChatThread { ChatId = Guid.NewGuid() };
|
||||
|
||||
autosave.Schedule(chat);
|
||||
await Task.Delay(SHORT_DELAY * 4);
|
||||
var storedWhileBusy = storage.StoredChats.Count;
|
||||
|
||||
storage.IsBusy = false;
|
||||
await storage.WaitForStoreAsync();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(storedWhileBusy, Is.Zero, "The AI job changes the conversation meanwhile, so storing it from elsewhere could read it in the middle of a change.");
|
||||
Assert.That(storage.StoredChats, Is.EqualTo(new[] { chat }), "Once the answer is done, the change must still reach the disk.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LeavingAChatBeingAnsweredLeavesItToTheJob()
|
||||
{
|
||||
var storage = new FakeStorage { IsBusy = true };
|
||||
await using var autosave = storage.CreateAutosave(LONG_DELAY);
|
||||
|
||||
autosave.Schedule(new ChatThread { ChatId = Guid.NewGuid() });
|
||||
await autosave.FlushAsync();
|
||||
|
||||
Assert.That(storage.StoredChats, Is.Empty, "The AI job stores the chat once more when the answer is done, together with the draft it shares.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AChatWhichIsNotOnTheDiskIsNeverStored()
|
||||
{
|
||||
var storage = new FakeStorage { IsStored = false };
|
||||
await using var autosave = storage.CreateAutosave(SHORT_DELAY);
|
||||
|
||||
autosave.Schedule(new ChatThread { ChatId = Guid.NewGuid() });
|
||||
await Task.Delay(SHORT_DELAY * 4);
|
||||
await autosave.FlushAsync();
|
||||
|
||||
Assert.That(storage.StoredChats, Is.Empty, "A chat gone from the disk was deleted, and storing it would bring it back.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheNewestCopyOfTheChatIsStored()
|
||||
{
|
||||
var storage = new FakeStorage();
|
||||
await using var autosave = storage.CreateAutosave(LONG_DELAY);
|
||||
var chat = new ChatThread { ChatId = Guid.NewGuid() };
|
||||
|
||||
autosave.Schedule(chat);
|
||||
|
||||
// What the chat does when the user picks another profile:
|
||||
var withOtherProfile = chat with { SelectedProfile = Guid.NewGuid().ToString() };
|
||||
autosave.Follow(withOtherProfile);
|
||||
await autosave.FlushAsync();
|
||||
|
||||
Assert.That(storage.StoredChats.Single(), Is.SameAs(withOtherProfile), "Storing the former copy would bring the former profile back.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AChangeInAnotherChatStoresThePreviousOne()
|
||||
{
|
||||
var storage = new FakeStorage();
|
||||
await using var autosave = storage.CreateAutosave(LONG_DELAY);
|
||||
var previousChat = new ChatThread { ChatId = Guid.NewGuid() };
|
||||
|
||||
autosave.Schedule(previousChat);
|
||||
autosave.Schedule(new ChatThread { ChatId = Guid.NewGuid() });
|
||||
await storage.WaitForStoreAsync();
|
||||
|
||||
Assert.That(storage.StoredChats.Single(), Is.SameAs(previousChat), "The change in one chat must not cost the other its draft.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DisposingStoresWhatIsPending()
|
||||
{
|
||||
var storage = new FakeStorage();
|
||||
var autosave = storage.CreateAutosave(LONG_DELAY);
|
||||
var chat = new ChatThread { ChatId = Guid.NewGuid() };
|
||||
|
||||
autosave.Schedule(chat);
|
||||
await autosave.DisposeAsync();
|
||||
autosave.Schedule(new ChatThread { ChatId = Guid.NewGuid() });
|
||||
await autosave.FlushAsync();
|
||||
|
||||
Assert.That(storage.StoredChats, Is.EqualTo(new[] { chat }), "Leaving the chat page disposes the chat, which counts as leaving the chat. What comes later belongs to no chat on the screen.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for the disk and for the AI jobs, and records which chats were stored.
|
||||
/// </summary>
|
||||
private sealed class FakeStorage
|
||||
{
|
||||
private readonly Lock storedLock = new();
|
||||
private readonly List<ChatThread> storedChats = [];
|
||||
private readonly SemaphoreSlim storeSignal = new(0);
|
||||
private volatile bool isBusy;
|
||||
private volatile bool isStored = true;
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => this.isBusy;
|
||||
set => this.isBusy = value;
|
||||
}
|
||||
|
||||
public bool IsStored
|
||||
{
|
||||
get => this.isStored;
|
||||
init => this.isStored = value;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ChatThread> StoredChats
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (this.storedLock)
|
||||
return [..this.storedChats];
|
||||
}
|
||||
}
|
||||
|
||||
public ChatDraftAutosave CreateAutosave(TimeSpan delay) => new(delay, _ => this.IsStored, _ => this.IsBusy, this.StoreAsync, NullLogger.Instance);
|
||||
|
||||
public async Task WaitForStoreAsync()
|
||||
{
|
||||
if (!await this.storeSignal.WaitAsync(WAIT_LIMIT))
|
||||
Assert.Fail("The chat was never stored.");
|
||||
}
|
||||
|
||||
private Task<bool> StoreAsync(ChatThread chat)
|
||||
{
|
||||
lock (this.storedLock)
|
||||
this.storedChats.Add(chat);
|
||||
|
||||
this.storeSignal.Release();
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Tools;
|
||||
|
||||
namespace AIStudio.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the draft of a chat, the message the user has not sent yet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The draft is stored with its chat, so the message is still there when the user comes back to it.
|
||||
/// Two parties change the draft: the user in the composer, and the media transcription service,
|
||||
/// which adds a finished transcript before the composer shows it. That is why a transcript only
|
||||
/// leaves the draft when the user removes it, or when the message is sent.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ChatDraftTests
|
||||
{
|
||||
private static readonly FileAttachment AGENDA = new(FileAttachmentType.DOCUMENT, "agenda.pdf", "/documents/agenda.pdf", 1024);
|
||||
private static readonly FileAttachment MINUTES = new(FileAttachmentType.DOCUMENT, "minutes.pdf", "/documents/minutes.pdf", 2048);
|
||||
private static readonly FileAttachmentImage CHART = new("chart.png", "/documents/chart.png", 4096);
|
||||
private static readonly ManagedTranscriptAttachment TRANSCRIPT = new("meeting-transcript-0001.md", "/data/tempChats/meeting-transcript-0001.md", 512, "meeting.mp4", false);
|
||||
private static readonly ManagedTranscriptAttachment STAGED_TRANSCRIPT = new("4f1c.md", "/data/media-staging/4f1c.md", 256, "kickoff.mp4", true);
|
||||
|
||||
[Test]
|
||||
public void MirroringTheComposerKeepsATranscriptItDoesNotShowYet()
|
||||
{
|
||||
var draft = new ChatDraft();
|
||||
draft.AddAttachments([AGENDA]);
|
||||
|
||||
// The transcription service finished, and the composer has not shown the transcript yet:
|
||||
draft.AddAttachments([TRANSCRIPT]);
|
||||
|
||||
// Meanwhile, the user replaced the agenda by the minutes:
|
||||
draft.MirrorAttachments([MINUTES]);
|
||||
|
||||
Assert.That(draft.FileAttachments, Is.EqualTo(new[] { MINUTES, TRANSCRIPT }), "The composer decides on the other attachments, but a transcript it does not show yet has to stay, or its file is left without an owner.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnAttachmentIsAddedOnlyOnce()
|
||||
{
|
||||
var draft = new ChatDraft();
|
||||
|
||||
var added = draft.AddAttachments([TRANSCRIPT]);
|
||||
var addedAgain = draft.AddAttachments([TRANSCRIPT with { FileSizeBytes = 1024 }]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(added, Is.True);
|
||||
Assert.That(addedAgain, Is.False, "The same file counts once, even in a newer state.");
|
||||
Assert.That(draft.FileAttachments, Is.EqualTo(new FileAttachment[] { TRANSCRIPT }), "The composer would show the transcript twice otherwise.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RemovingAnAttachmentFindsItByItsPath()
|
||||
{
|
||||
var draft = new ChatDraft();
|
||||
draft.AddAttachments([AGENDA, TRANSCRIPT]);
|
||||
|
||||
// The composer holds the transcript as it normalized it, with the current size of its file:
|
||||
draft.RemoveAttachments([TRANSCRIPT with { FileSizeBytes = 1024 }]);
|
||||
|
||||
Assert.That(draft.FileAttachments, Is.EqualTo(new[] { AGENDA }), "The transcript the user removed must not come back when the chat is opened again.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReplacingTheAttachmentsKeepsTheirKind()
|
||||
{
|
||||
var draft = new ChatDraft();
|
||||
draft.AddAttachments([AGENDA, TRANSCRIPT]);
|
||||
|
||||
draft.ReplaceAttachments(attachment => attachment with { FilePath = $"/moved{attachment.FilePath}" });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(draft.FileAttachments.Select(attachment => attachment.FilePath), Is.EqualTo(new[] { "/moved/documents/agenda.pdf", "/moved/data/tempChats/meeting-transcript-0001.md" }));
|
||||
Assert.That(draft.MediaTranscripts, Has.Count.EqualTo(1), "A transcript stays a transcript, so removing it from the composer still deletes its file.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EditingAMessageKeepsTheTranscriptsOfTheDraft()
|
||||
{
|
||||
var draft = new ChatDraft();
|
||||
draft.SetText("An older draft");
|
||||
draft.AddAttachments([AGENDA, TRANSCRIPT]);
|
||||
|
||||
draft.RestoreFromMessage(new ContentText { Text = "Summarize the minutes", FileAttachments = [MINUTES] });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(draft.Text, Is.EqualTo("Summarize the minutes"), "The message to edit replaces the older draft.");
|
||||
Assert.That(draft.FileAttachments, Is.EqualTo(new[] { MINUTES, TRANSCRIPT }), "The draft is the only owner of its transcripts, so dropping them would leave their files behind.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SendingEmptiesTheDraft()
|
||||
{
|
||||
var draft = new ChatDraft();
|
||||
draft.SetText("Summarize the meeting");
|
||||
draft.AddAttachments([TRANSCRIPT]);
|
||||
|
||||
draft.Clear();
|
||||
|
||||
Assert.That(draft.IsEmpty, Is.True, "The attachments belong to the sent message now, and a transcript has only one owner.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheDraftKeepsTheKindOfEveryAttachmentWhenStored()
|
||||
{
|
||||
var thread = new ChatThread { ChatId = Guid.NewGuid(), Name = "Meeting notes" };
|
||||
thread.Draft.SetText("Summarize the meeting");
|
||||
thread.Draft.AddAttachments([AGENDA, CHART, STAGED_TRANSCRIPT]);
|
||||
|
||||
var loaded = JsonSerializer.Deserialize<ChatThread>(JsonSerializer.Serialize(thread, WorkspaceBehaviour.JSON_OPTIONS), WorkspaceBehaviour.JSON_OPTIONS)!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(loaded.Draft.Text, Is.EqualTo("Summarize the meeting"));
|
||||
Assert.That(loaded.Draft.FileAttachments, Is.EqualTo(new[] { AGENDA, CHART, STAGED_TRANSCRIPT }), "A transcript loaded as a plain document would no longer be managed, and its file would never be deleted.");
|
||||
Assert.That(loaded.Draft.MediaTranscripts.Single().IsStaged, Is.True, "A staged transcript has to move into the chat directory once the chat is stored.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AChatOfTheFormerVersionBringsItsPendingTranscriptsIntoTheDraft()
|
||||
{
|
||||
// How v26.9.1 stored a chat with a transcript which was not sent yet, without a type discriminator:
|
||||
const string CHAT_WITH_PENDING_TRANSCRIPTS = """
|
||||
{
|
||||
"chat_id": "2b7c9e1f-4a6d-4c8b-9e3f-1d5a7c9b2e4f",
|
||||
"pending_media_transcripts": [
|
||||
{
|
||||
"original_file_name": "meeting.mp4",
|
||||
"is_staged": false,
|
||||
"type": "DOCUMENT",
|
||||
"file_name": "meeting-transcript-0001.md",
|
||||
"file_path": "/data/tempChats/meeting-transcript-0001.md",
|
||||
"file_size_bytes": 512,
|
||||
"is_forbidden": false,
|
||||
"is_valid": true,
|
||||
"is_image": false,
|
||||
"file_path_as_url": "/local/file?path=%2Fdata%2FtempChats%2Fmeeting-transcript-0001.md",
|
||||
"exists": true
|
||||
}
|
||||
],
|
||||
"name": "Meeting notes",
|
||||
"blocks": []
|
||||
}
|
||||
""";
|
||||
|
||||
var loaded = JsonSerializer.Deserialize<ChatThread>(CHAT_WITH_PENDING_TRANSCRIPTS, WorkspaceBehaviour.JSON_OPTIONS)!;
|
||||
var stored = JsonSerializer.Serialize(loaded, WorkspaceBehaviour.JSON_OPTIONS);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(loaded.Draft.FileAttachments, Is.EqualTo(new FileAttachment[] { TRANSCRIPT }), "Opening the chat has to bring the transcript back into the composer.");
|
||||
Assert.That(stored, Does.Not.Contain("pending_media_transcripts"), "The draft holds the transcript now, so storing the chat must not write the former list again.");
|
||||
Assert.That(stored, Does.Contain("\"draft\""));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AChatWithoutADraftOpensWithAnEmptyOne()
|
||||
{
|
||||
const string CHAT_WITHOUT_DRAFT = """
|
||||
{
|
||||
"chat_id": "2b7c9e1f-4a6d-4c8b-9e3f-1d5a7c9b2e4f",
|
||||
"name": "An older chat",
|
||||
"blocks": []
|
||||
}
|
||||
""";
|
||||
|
||||
var loaded = JsonSerializer.Deserialize<ChatThread>(CHAT_WITHOUT_DRAFT, WorkspaceBehaviour.JSON_OPTIONS);
|
||||
|
||||
Assert.That(loaded?.Draft.IsEmpty, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ACopyOfTheSameChatSharesTheDraft()
|
||||
{
|
||||
var thread = new ChatThread { ChatId = Guid.NewGuid() };
|
||||
|
||||
// What the chat does when the user picks another profile:
|
||||
var withOtherProfile = thread with { SelectedProfile = Guid.NewGuid().ToString() };
|
||||
withOtherProfile.Draft.SetText("Summarize the meeting");
|
||||
|
||||
Assert.That(thread.Draft.Text, Is.EqualTo("Summarize the meeting"), "Both are the same chat, and the transcription service may still hold the former one.");
|
||||
}
|
||||
}
|
||||
@@ -63,16 +63,17 @@ public sealed class ChatThreadEditTests
|
||||
var composer = new ChatComposerState();
|
||||
|
||||
// What the chat does when the user stops the answer and edits the question:
|
||||
composer.RestoreFromTextBlock((ContentText)question.Content!);
|
||||
thread.RemoveForEditing(question.Content!);
|
||||
thread.Remove(answer.Content!);
|
||||
composer.RestoreFromDraft(thread.Draft);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(thread.Blocks, Is.Empty, "Both the question and the stopped answer leave the chat.");
|
||||
Assert.That(File.Exists(transcript.FilePath), Is.True, "The composer still carries the transcript, so its file has to stay.");
|
||||
Assert.That(composer.FileAttachments.Single().Exists, Is.True, "The edited message goes out with the transcript, not with a reference to a deleted file.");
|
||||
Assert.That(thread.PendingMediaTranscripts.Select(x => x.FilePath), Is.EqualTo(new[] { transcript.FilePath }), "The draft owns the transcript now: opening the chat again brings it back, and removing it from the composer deletes the file.");
|
||||
Assert.That(thread.Draft.MediaTranscripts.Select(x => x.FilePath), Is.EqualTo(new[] { transcript.FilePath }), "The draft owns the transcript now: opening the chat again brings it back, and removing it from the composer deletes the file.");
|
||||
Assert.That(thread.Draft.Text, Is.EqualTo("Summarize the meeting"), "The message being edited is the draft now, its text included.");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,16 +83,12 @@ public sealed class ChatThreadEditTests
|
||||
var chatId = Guid.NewGuid();
|
||||
var transcript = this.CreateTranscript(chatId, "meeting");
|
||||
var question = Block(ChatRole.USER, "Summarize the meeting", 1, transcript);
|
||||
var thread = new ChatThread
|
||||
{
|
||||
ChatId = chatId,
|
||||
PendingMediaTranscripts = [transcript],
|
||||
Blocks = [question],
|
||||
};
|
||||
var thread = new ChatThread { ChatId = chatId, Blocks = [question] };
|
||||
thread.Draft.AddAttachments([transcript]);
|
||||
|
||||
thread.RemoveForEditing(question.Content!);
|
||||
|
||||
Assert.That(thread.PendingMediaTranscripts, Has.Count.EqualTo(1), "The composer would show the same transcript twice otherwise.");
|
||||
Assert.That(thread.Draft.FileAttachments, Has.Count.EqualTo(1), "The composer would show the same transcript twice otherwise.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -107,7 +104,7 @@ public sealed class ChatThreadEditTests
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(File.Exists(transcript.FilePath), Is.False, "Nothing refers to the transcript of a deleted message any longer, so its file has to go.");
|
||||
Assert.That(thread.PendingMediaTranscripts, Is.Empty, "A deleted message hands nothing over to the draft.");
|
||||
Assert.That(thread.Draft.IsEmpty, Is.True, "A deleted message hands nothing over to the draft.");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -127,7 +124,7 @@ public sealed class ChatThreadEditTests
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(thread.Blocks, Has.Count.EqualTo(2), "The message to edit is not part of this chat, so no block goes.");
|
||||
Assert.That(thread.PendingMediaTranscripts, Is.Empty, "A transcript of a message this chat does not hold never becomes part of its draft.");
|
||||
Assert.That(thread.Draft.IsEmpty, Is.True, "A message this chat does not hold never becomes its draft.");
|
||||
Assert.That(File.Exists(transcript.FilePath), Is.True);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Components;
|
||||
using AIStudio.Settings;
|
||||
|
||||
namespace AIStudio.Tests.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Checks what the composer counts as a message the user started.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// That decides whether a chat template may still fill the composer, and whether leaving the chat
|
||||
/// warns about unsaved changes. A draft brought back from a chat is always the user's own, while a
|
||||
/// chat template only suggests a message.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ChatComposerStateTests
|
||||
{
|
||||
[Test]
|
||||
public void ADraftBroughtBackIsTheMessageOfTheUser()
|
||||
{
|
||||
var draft = new ChatDraft();
|
||||
draft.SetText("Summarize the meeting");
|
||||
draft.AddAttachments([new FileAttachment(FileAttachmentType.DOCUMENT, "agenda.pdf", "/documents/agenda.pdf", 1024)]);
|
||||
var composer = new ChatComposerState();
|
||||
|
||||
composer.RestoreFromDraft(draft);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(composer.UserInput, Is.EqualTo("Summarize the meeting"));
|
||||
Assert.That(composer.FileAttachments.Select(attachment => attachment.FilePath), Is.EqualTo(new[] { "/documents/agenda.pdf" }));
|
||||
Assert.That(composer.HasUserDraft, Is.True, "A chat template must not replace the message the user came back to.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnEmptyDraftLeavesTheComposerFree()
|
||||
{
|
||||
var composer = new ChatComposerState();
|
||||
composer.SetUserInput("A message of the chat before");
|
||||
|
||||
composer.RestoreFromDraft(new ChatDraft());
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(composer.UserInput, Is.Empty, "The message of the chat before belongs to that chat.");
|
||||
Assert.That(composer.HasUserDraft, Is.False, "Nobody started a message in this chat, so a chat template may still fill the composer.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheMessageOfAChatTemplateIsNoDraftOfTheUser()
|
||||
{
|
||||
var composer = new ChatComposerState();
|
||||
|
||||
composer.ApplyTemplate(new ChatTemplate { PredefinedUserPrompt = "Translate the following text:" });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(composer.UserInput, Is.EqualTo("Translate the following text:"));
|
||||
Assert.That(composer.HasUserDraft, Is.False, "Leaving the chat must not warn about a message the user never wrote.");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools;
|
||||
|
||||
namespace AIStudio.Tests.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Checks what a copy of a chat makes of the message the user has not sent yet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The copy continues where the original stands, so it takes the draft along. A transcript of the
|
||||
/// draft is a file the chat manages itself, though: removing it from the composer deletes the file.
|
||||
/// Shared between both chats, a transcript removed in the copy would be missing in the original.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
[NonParallelizable]
|
||||
public sealed class WorkspaceChatCopyTests
|
||||
{
|
||||
private string? previousDataDirectory;
|
||||
private string testDirectory = string.Empty;
|
||||
|
||||
[SetUp]
|
||||
public void PrepareDataDirectory()
|
||||
{
|
||||
//
|
||||
// The data directory is static state of the whole application, which is why this fixture
|
||||
// does not run alongside others. The chats live below it, and so do their transcripts.
|
||||
//
|
||||
this.previousDataDirectory = SettingsManager.DataDirectory;
|
||||
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-chat-copy-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(this.testDirectory);
|
||||
SettingsManager.DataDirectory = this.testDirectory;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void RemoveDataDirectory()
|
||||
{
|
||||
SettingsManager.DataDirectory = this.previousDataDirectory;
|
||||
|
||||
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 TheCopyGetsADraftWithTranscriptsOfItsOwn()
|
||||
{
|
||||
var chatId = Guid.NewGuid();
|
||||
var transcript = this.CreateTranscript(chatId, "meeting");
|
||||
var original = new ChatThread { ChatId = chatId, Name = "Meeting notes", LastMediaTranscriptNumber = 1 };
|
||||
original.Draft.SetText("Summarize the meeting");
|
||||
original.Draft.AddAttachments([transcript]);
|
||||
|
||||
var copy = await WorkspaceBehaviour.CopyChatAsync(original, "Copy of Meeting notes");
|
||||
|
||||
var copyDirectory = Path.Combine(this.testDirectory, "tempChats", copy.ChatId.ToString());
|
||||
var copiedTranscript = copy.Draft.MediaTranscripts.Single();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(copy.Draft, Is.Not.SameAs(original.Draft), "Writing in the copy must not change the message of the original.");
|
||||
Assert.That(copy.Draft.Text, Is.EqualTo("Summarize the meeting"), "The copy continues where the original stands, the message being written included.");
|
||||
Assert.That(copiedTranscript.FilePath, Does.StartWith(copyDirectory), "The copy has to own a transcript of its own.");
|
||||
Assert.That(File.Exists(copiedTranscript.FilePath), Is.True, "Otherwise the transcript drops out of the message of the copy.");
|
||||
Assert.That(original.Draft.MediaTranscripts.Single(), Is.EqualTo(transcript), "The original keeps its transcript, with its path.");
|
||||
Assert.That(File.Exists(transcript.FilePath), Is.True, "Copying a chat must not take anything away from the original.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a transcript where a temporary chat keeps its transcripts.
|
||||
/// </summary>
|
||||
/// <param name="chatId">The chat which owns the transcript.</param>
|
||||
/// <param name="stem">The name of the media file, without its extension.</param>
|
||||
/// <returns>The transcript, as the chat attaches it to a message.</returns>
|
||||
private ManagedTranscriptAttachment CreateTranscript(Guid chatId, string stem)
|
||||
{
|
||||
var transcriptDirectory = Path.Combine(this.testDirectory, "tempChats", chatId.ToString(), "attachments", "transcripts");
|
||||
Directory.CreateDirectory(transcriptDirectory);
|
||||
|
||||
var path = Path.Combine(transcriptDirectory, $"{stem}-transcript-0001.md");
|
||||
File.WriteAllText(path, $"# Transcription: {stem}.mp4{Environment.NewLine}{Environment.NewLine}Hello and welcome.");
|
||||
return new ManagedTranscriptAttachment(Path.GetFileName(path), path, new FileInfo(path).Length, $"{stem}.mp4", false);
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,6 @@ public sealed class WorkspaceChatMoveTests
|
||||
{
|
||||
ChatId = chatId,
|
||||
Name = "Meeting notes",
|
||||
PendingMediaTranscripts = [draft],
|
||||
Blocks =
|
||||
[
|
||||
new ContentBlock
|
||||
@@ -75,11 +74,12 @@ public sealed class WorkspaceChatMoveTests
|
||||
},
|
||||
],
|
||||
};
|
||||
chat.Draft.AddAttachments([draft]);
|
||||
|
||||
await WorkspaceBehaviour.MoveChatAsync(chat, workspaceId);
|
||||
|
||||
var targetDirectory = Path.Combine(this.testDirectory, "workspaces", workspaceId.ToString(), chatId.ToString());
|
||||
var movedDraft = chat.PendingMediaTranscripts.Single();
|
||||
var movedDraft = chat.Draft.MediaTranscripts.Single();
|
||||
var movedSent = ((ContentText)chat.Blocks.Single().Content!).FileAttachments.Single();
|
||||
var stored = JsonSerializer.Deserialize<ChatThread>(await File.ReadAllTextAsync(Path.Combine(targetDirectory, "thread.json")), WorkspaceBehaviour.JSON_OPTIONS)!;
|
||||
|
||||
@@ -89,7 +89,7 @@ public sealed class WorkspaceChatMoveTests
|
||||
Assert.That(movedDraft.FilePath, Does.StartWith(targetDirectory), "The draft has to point into the directory the chat lives in now.");
|
||||
Assert.That(File.Exists(movedDraft.FilePath), Is.True, "Otherwise the transcript drops out of the message which is still being written.");
|
||||
Assert.That(File.Exists(movedSent.FilePath), Is.True, "The transcripts of the sent messages move along, as they always did.");
|
||||
Assert.That(stored.PendingMediaTranscripts.Single().FilePath, Is.EqualTo(movedDraft.FilePath), "Opening the chat again brings the draft back from the stored chat, so that has to know the new path as well.");
|
||||
Assert.That(stored.Draft.MediaTranscripts.Single().FilePath, Is.EqualTo(movedDraft.FilePath), "Opening the chat again brings the draft back from the stored chat, so that has to know the new path as well.");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,11 +102,12 @@ public sealed class WorkspaceChatMoveTests
|
||||
// A transcript which is still waiting in the staging area, because its chat was not stored yet when it was made:
|
||||
var stagingDirectory = Path.Combine(this.testDirectory, "media-staging", Guid.NewGuid().ToString("N"));
|
||||
var staged = WriteTranscript(stagingDirectory, $"{Guid.NewGuid():N}.md", "meeting", isStaged: true);
|
||||
var chat = new ChatThread { ChatId = chatId, Name = "Meeting notes", PendingMediaTranscripts = [staged] };
|
||||
var chat = new ChatThread { ChatId = chatId, Name = "Meeting notes" };
|
||||
chat.Draft.AddAttachments([staged]);
|
||||
|
||||
await WorkspaceBehaviour.MoveChatAsync(chat, Guid.NewGuid());
|
||||
|
||||
Assert.That(chat.PendingMediaTranscripts.Single().FilePath, Is.EqualTo(staged.FilePath), "The move takes only the chat directory along, so a path elsewhere is still right.");
|
||||
Assert.That(chat.Draft.MediaTranscripts.Single().FilePath, Is.EqualTo(staged.FilePath), "The move takes only the chat directory along, so a path elsewhere is still right.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -116,7 +117,8 @@ public sealed class WorkspaceChatMoveTests
|
||||
var workspaceId = Guid.NewGuid();
|
||||
var transcript = this.CreateTranscript(chatId, "meeting", 1);
|
||||
var document = FileAttachment.FromPath(Path.Combine(this.testDirectory, "documents", "agenda.pdf"));
|
||||
var chat = new ChatThread { ChatId = chatId, Name = "Meeting notes", PendingMediaTranscripts = [transcript] };
|
||||
var chat = new ChatThread { ChatId = chatId, Name = "Meeting notes" };
|
||||
chat.Draft.AddAttachments([transcript]);
|
||||
|
||||
await WorkspaceBehaviour.MoveChatAsync(chat, workspaceId);
|
||||
|
||||
@@ -126,7 +128,7 @@ public sealed class WorkspaceChatMoveTests
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(relocatedTranscript, Is.EqualTo(chat.PendingMediaTranscripts.Single()), "The composer and the draft have to agree, or the composer shows the transcript twice.");
|
||||
Assert.That(relocatedTranscript, Is.EqualTo(chat.Draft.MediaTranscripts.Single()), "The composer and the draft have to agree, or the composer shows the transcript twice.");
|
||||
Assert.That(relocatedTranscript.Exists, Is.True, "The message being written has to go out with the transcript.");
|
||||
Assert.That(relocatedTranscript, Is.InstanceOf<ManagedTranscriptAttachment>(), "The chat still manages the transcript, so removing it from the composer still deletes the file.");
|
||||
Assert.That(relocatedDocument, Is.SameAs(document), "The move did not touch the document, so it keeps its path.");
|
||||
|
||||
Reference in new issue
Block a user