mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-13 04:53:47 +00:00
Kept media transcripts when editing a message or moving a chat (#1047)
This commit is contained in:
1 parent
3a1f586f76
commit
5ee4dfa2b5
8 files changed
+461
-28
No files matched your search
@@ -0,0 +1,158 @@
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Components;
|
||||
using AIStudio.Settings;
|
||||
|
||||
namespace AIStudio.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Checks what editing a message does to the transcripts of its audio and video files.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Editing hands a message back to the composer, attachments included, and removes it from the chat.
|
||||
/// A transcript is a file the chat manages itself, and removing a message deletes such files. After
|
||||
/// an edit, though, the composer still points at them, so the edit has to keep the files and hand them
|
||||
/// over to the draft. Deleting a message goes on deleting them.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
[NonParallelizable]
|
||||
public sealed class ChatThreadEditTests
|
||||
{
|
||||
private static readonly DateTimeOffset START = new(2026, 10, 11, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
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. A transcript counts as one the chat manages only below that
|
||||
// directory; anywhere else, removing a message would never delete it, and the tests would
|
||||
// pass for the wrong reason.
|
||||
//
|
||||
this.previousDataDirectory = SettingsManager.DataDirectory;
|
||||
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-chat-edit-{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 void TheTranscriptOfAnEditedMessageStaysWithTheDraft()
|
||||
{
|
||||
var chatId = Guid.NewGuid();
|
||||
var transcript = this.CreateTranscript(chatId, "meeting");
|
||||
var question = Block(ChatRole.USER, "Summarize the meeting", 1, transcript);
|
||||
var answer = Block(ChatRole.AI, "The meeting was about", 2);
|
||||
var thread = new ChatThread { ChatId = chatId, Blocks = [question, answer] };
|
||||
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!);
|
||||
|
||||
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.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ATranscriptWhichIsAlreadyPendingIsNotAddedTwice()
|
||||
{
|
||||
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],
|
||||
};
|
||||
|
||||
thread.RemoveForEditing(question.Content!);
|
||||
|
||||
Assert.That(thread.PendingMediaTranscripts, Has.Count.EqualTo(1), "The composer would show the same transcript twice otherwise.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DeletingAMessageStillDeletesItsTranscript()
|
||||
{
|
||||
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, Blocks = [question, Block(ChatRole.AI, "The meeting was about", 2)] };
|
||||
|
||||
thread.Remove(question.Content!);
|
||||
|
||||
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.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EditingAMessageOfAnotherChatChangesNothing()
|
||||
{
|
||||
var chatId = Guid.NewGuid();
|
||||
var transcript = this.CreateTranscript(chatId, "meeting");
|
||||
var thread = new ChatThread
|
||||
{
|
||||
ChatId = chatId,
|
||||
Blocks = [Block(ChatRole.USER, "Summarize the meeting", 1, transcript), Block(ChatRole.AI, "The meeting was about", 2)],
|
||||
};
|
||||
|
||||
thread.RemoveForEditing(new ContentText { Text = "Question of another chat", FileAttachments = [transcript] });
|
||||
|
||||
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(File.Exists(transcript.FilePath), Is.True);
|
||||
});
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
private static ContentBlock Block(ChatRole role, string text, int minute, params FileAttachment[] attachments) => new()
|
||||
{
|
||||
Time = START.AddMinutes(minute),
|
||||
ContentType = ContentType.TEXT,
|
||||
Content = new ContentText { Text = text, FileAttachments = [..attachments] },
|
||||
Role = role,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools;
|
||||
|
||||
namespace AIStudio.Tests.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Checks where the transcripts of a chat point after the chat moved into a workspace.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Moving a chat moves its whole directory, transcripts included. Every reference to a transcript
|
||||
/// therefore needs its new path: the ones of the sent messages, and the ones of the message the user
|
||||
/// is still writing. A reference left behind points at a directory which no longer exists, and the
|
||||
/// transcript silently drops out of the message.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
[NonParallelizable]
|
||||
public sealed class WorkspaceChatMoveTests
|
||||
{
|
||||
private static readonly DateTimeOffset START = new(2026, 10, 11, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
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-move-{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 TheTranscriptsOfTheDraftMoveAlongWithTheChat()
|
||||
{
|
||||
var chatId = Guid.NewGuid();
|
||||
var workspaceId = Guid.NewGuid();
|
||||
var sent = this.CreateTranscript(chatId, "kickoff", 1);
|
||||
var draft = this.CreateTranscript(chatId, "meeting", 2);
|
||||
var chat = new ChatThread
|
||||
{
|
||||
ChatId = chatId,
|
||||
Name = "Meeting notes",
|
||||
PendingMediaTranscripts = [draft],
|
||||
Blocks =
|
||||
[
|
||||
new ContentBlock
|
||||
{
|
||||
Time = START,
|
||||
ContentType = ContentType.TEXT,
|
||||
Content = new ContentText { Text = "Summarize the kickoff", FileAttachments = [sent] },
|
||||
Role = ChatRole.USER,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await WorkspaceBehaviour.MoveChatAsync(chat, workspaceId);
|
||||
|
||||
var targetDirectory = Path.Combine(this.testDirectory, "workspaces", workspaceId.ToString(), chatId.ToString());
|
||||
var movedDraft = chat.PendingMediaTranscripts.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)!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(File.Exists(draft.FilePath), Is.False, "The move takes the whole chat directory along, so nothing stays at the old path.");
|
||||
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.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ATranscriptOutsideTheChatDirectoryKeepsItsPath()
|
||||
{
|
||||
var chatId = Guid.NewGuid();
|
||||
Directory.CreateDirectory(Path.Combine(this.testDirectory, "tempChats", chatId.ToString()));
|
||||
|
||||
// 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] };
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheComposerOfTheOpenChatFollowsTheMove()
|
||||
{
|
||||
var chatId = Guid.NewGuid();
|
||||
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] };
|
||||
|
||||
await WorkspaceBehaviour.MoveChatAsync(chat, workspaceId);
|
||||
|
||||
// What the composer of the open chat holds: the transcript of the draft, and a document from elsewhere:
|
||||
var relocatedTranscript = WorkspaceBehaviour.RelocateAttachmentAfterMove(transcript, chatId, Guid.Empty, workspaceId);
|
||||
var relocatedDocument = WorkspaceBehaviour.RelocateAttachmentAfterMove(document, chatId, Guid.Empty, workspaceId);
|
||||
|
||||
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.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.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <param name="number">The number of the transcript within the chat.</param>
|
||||
/// <returns>The transcript, as the chat attaches it to a message.</returns>
|
||||
private ManagedTranscriptAttachment CreateTranscript(Guid chatId, string stem, int number)
|
||||
{
|
||||
var transcriptDirectory = Path.Combine(this.testDirectory, "tempChats", chatId.ToString(), "attachments", "transcripts");
|
||||
return WriteTranscript(transcriptDirectory, $"{stem}-transcript-{number:D4}.md", stem, isStaged: false);
|
||||
}
|
||||
|
||||
private static ManagedTranscriptAttachment WriteTranscript(string directory, string fileName, string stem, bool isStaged)
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
var path = Path.Combine(directory, fileName);
|
||||
File.WriteAllText(path, $"# Transcription: {stem}.mp4{Environment.NewLine}{Environment.NewLine}Hello and welcome.");
|
||||
return new ManagedTranscriptAttachment(fileName, path, new FileInfo(path).Length, $"{stem}.mp4", isStaged);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user