Moved the chats of the BeyondFacts Lab to the embedded chat of assistants (#1203)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-15 10:32:46 +01:00
1 parent dc97db256e
commit cb54ad297d
27 files changed
+1023 -743

No files matched your search

@@ -4,13 +4,15 @@ using AIStudio.Chat.Embedded;
namespace AIStudio.Tests.Chat.Embedded;
/// <summary>
/// Checks when an embedded chat sends the message it opens with.
/// Checks when an embedded chat sends the message it opens with, and when it offers to let the model begin.
/// </summary>
[TestFixture]
public sealed class EmbeddedChatOpeningTests
{
private static readonly ContentText OPENING_MESSAGE = new() { Text = "What is new today?" };
private const string START_REQUEST = "Open the conversation with your first message.";
[Test]
public void AnEmptyChatSendsItsOpeningMessage()
{
@@ -61,6 +63,57 @@ public sealed class EmbeddedChatOpeningTests
Assert.That(EmbeddedChatOpening.SendsNow(OPENING_MESSAGE, conversation, wasSent: false), Is.False);
}
[Test]
public void AnEmptyChatOffersToBegin()
{
Assert.That(EmbeddedChatOpening.OffersStart(START_REQUEST, Conversation()), Is.True);
}
[Test]
public void AChatWithoutStartRequestOffersNothing()
{
using (Assert.EnterMultipleScope())
{
Assert.That(EmbeddedChatOpening.OffersStart(null, Conversation()), Is.False);
Assert.That(EmbeddedChatOpening.OffersStart(" ", Conversation()), Is.False);
}
}
[Test]
public void AChatWhoseConversationBeganOffersNothing()
{
var conversation = Conversation();
conversation.Thread!.Blocks.Add(new ContentBlock { Role = ChatRole.AI, ContentType = ContentType.TEXT, Content = new ContentText { Text = "Hello, what do you prepare?" } });
Assert.That(EmbeddedChatOpening.OffersStart(START_REQUEST, conversation), Is.False);
}
[Test]
public void AChatWithOnlyAHiddenRequestStillOffersToBegin()
{
// E.g., the model failed to answer the request which lets it begin:
var conversation = Conversation();
conversation.Thread!.Blocks.Add(new ContentBlock { Role = ChatRole.USER, ContentType = ContentType.TEXT, Content = new ContentText { Text = START_REQUEST }, HideFromUser = true });
Assert.That(EmbeddedChatOpening.OffersStart(START_REQUEST, conversation), Is.True);
}
[Test]
public void AChatWhichLoadsRunsOrCannotBeOpenedOffersNothing()
{
var running = Conversation();
running.IsRunning = true;
var unavailable = Conversation();
unavailable.Thread = null;
using (Assert.EnterMultipleScope())
{
Assert.That(EmbeddedChatOpening.OffersStart(START_REQUEST, null), Is.False);
Assert.That(EmbeddedChatOpening.OffersStart(START_REQUEST, running), Is.False);
Assert.That(EmbeddedChatOpening.OffersStart(START_REQUEST, unavailable), Is.False);
}
}
private static EmbeddedConversation Conversation() => new()
{
ChatId = Guid.NewGuid(),
@@ -130,7 +130,7 @@ public sealed class EmbeddedChatServiceTests
var service = this.CreateService(Answer("An answer."));
var conversation = await service.GetAsync(store, COMPONENT, chatId, () => new ChatThread());
var sent = await service.SendAsync(chatId, this.Request("A question.") with { PrepareAsync = _ => Task.FromResult(false) });
var sent = await service.SendAsync(chatId, this.Request("A question.") with { PrepareAsync = _ => Task.FromResult(EmbeddedChatPreparation.REFUSED) });
using (Assert.EnterMultipleScope())
{
@@ -202,7 +202,8 @@ public sealed class EmbeddedChatServiceTests
await service.GetAsync(store, COMPONENT, chatId, () => new ChatThread());
var extraBlock = new ContentBlock { Role = ChatRole.USER, ContentType = ContentType.TEXT, Content = new ContentText { Text = "An image description." } };
await service.SendAsync(chatId, this.Request("A question.") with { ThreadForRequest = thread => thread with { Blocks = [extraBlock, .. thread.Blocks] } });
var preparation = EmbeddedChatPreparation.READY with { ThreadForRequest = thread => thread with { Blocks = [extraBlock, .. thread.Blocks] } };
await service.SendAsync(chatId, this.Request("A question.") with { PrepareAsync = _ => Task.FromResult(preparation) });
using (Assert.EnterMultipleScope())
{
@@ -228,6 +229,83 @@ public sealed class EmbeddedChatServiceTests
}
}
[Test]
public async Task StartingOverDeletesTheConversationAndKeepsTheIdOfTheChat()
{
var store = new InMemoryEmbeddedChatStore();
var chatId = Guid.NewGuid();
var service = this.CreateService(Answer("An answer."));
var conversation = await service.GetAsync(store, COMPONENT, chatId, () => new ChatThread());
await service.SendAsync(chatId, this.Request("A question."));
var reset = await service.ResetAsync(chatId, () => new ChatThread { Name = "Started over" });
using (Assert.EnterMultipleScope())
{
Assert.That(reset, Is.True);
Assert.That(store.IsStored(chatId), Is.False, "The stored conversation is gone.");
Assert.That(conversation.Thread!.Blocks, Is.Empty);
Assert.That(conversation.Thread.ChatId, Is.EqualTo(chatId));
Assert.That(conversation.Thread.Name, Is.EqualTo("Started over"), "The new conversation starts as a new chat does.");
}
}
[Test]
public async Task AConversationDoesNotStartOverWhileTheModelAnswers()
{
var store = new InMemoryEmbeddedChatStore();
var chatId = Guid.NewGuid();
var answering = new TaskCompletionSource();
var release = new TaskCompletionSource();
var service = this.CreateService(async (_, answer, _, _) =>
{
answering.SetResult();
await release.Task;
answer.Text = "An answer.";
});
var conversation = await service.GetAsync(store, COMPONENT, chatId, () => new ChatThread());
var sending = service.SendAsync(chatId, this.Request("A question."));
await answering.Task;
var reset = await service.ResetAsync(chatId, () => new ChatThread());
release.SetResult();
await sending;
using (Assert.EnterMultipleScope())
{
Assert.That(reset, Is.False);
Assert.That(conversation.Thread!.Blocks, Has.Count.EqualTo(2), "The question and its answer stay.");
Assert.That(store.IsStored(chatId), Is.True);
}
}
[Test]
public async Task AChatThisVersionCannotOpenBecomesAvailableWhenItStartsOver()
{
var store = new InMemoryEmbeddedChatStore { CannotOpen = true };
var chatId = Guid.NewGuid();
var service = this.CreateService(Answer("An answer."));
var conversation = await service.GetAsync(store, COMPONENT, chatId, () => new ChatThread());
var wasAvailable = conversation.IsAvailable;
var reset = await service.ResetAsync(chatId, () => new ChatThread());
using (Assert.EnterMultipleScope())
{
Assert.That(wasAvailable, Is.False);
Assert.That(reset, Is.True);
Assert.That(conversation.IsAvailable, Is.True);
}
}
[Test]
public async Task AChatWhichWasNeverLoadedDoesNotStartOver()
{
var service = this.CreateService(Answer("An answer."));
Assert.That(await service.ResetAsync(Guid.NewGuid(), () => new ChatThread()), Is.False);
}
private EmbeddedChatService CreateService(Func<EmbeddedChatRequest, ContentText, ChatThread, CancellationToken, Task> answerAsync) => new(this.settingsManager, new AssistantSessionService(MessageBus.INSTANCE), MessageBus.INSTANCE, NullLogger<EmbeddedChatService>.Instance, answerAsync);
private EmbeddedChatRequest Request(string text) => new() { Provider = this.provider, Message = new ContentText { Text = text } };
@@ -0,0 +1,49 @@
using AIStudio.Chat;
using AIStudio.Chat.Embedded;
using AIStudio.Models;
namespace AIStudio.Tests.Chat.Embedded;
/// <summary>
/// Checks when an embedded chat which shows the context window warns about a full context, and when it stops sending.
/// </summary>
[TestFixture]
public sealed class EmbeddedChatTokenBudgetTests
{
private const int WINDOW = 32_000;
[Test]
public void ACountWhichFailedTellsNothing()
{
Assert.That(EmbeddedChatTokenBudget.Of(ConversationTokens.UNAVAILABLE), Is.EqualTo(EmbeddedChatTokenBudgetState.UNKNOWN));
}
[Test]
public void AModelWithoutAStatementAboutItsWindowTellsNothing()
{
var tokens = new ConversationTokens { IsKnown = true, HistoryTokens = 500_000, Window = ContextWindow.UNKNOWN };
Assert.That(EmbeddedChatTokenBudget.Of(tokens), Is.EqualTo(EmbeddedChatTokenBudgetState.UNKNOWN), "The chat cannot know when such a model is full, so it never stops sending.");
}
[TestCase(0, EmbeddedChatTokenBudgetState.OK)]
[TestCase(25_599, EmbeddedChatTokenBudgetState.OK)]
[TestCase(25_600, EmbeddedChatTokenBudgetState.WARNING)]
[TestCase(WINDOW - EmbeddedChatTokenBudget.RESERVE_TOKENS, EmbeddedChatTokenBudgetState.WARNING)]
[TestCase(WINDOW - EmbeddedChatTokenBudget.RESERVE_TOKENS + 1, EmbeddedChatTokenBudgetState.FULL)]
[TestCase(WINDOW * 2, EmbeddedChatTokenBudgetState.FULL)]
public void TheShareOfTheWindowDecides(int historyTokens, EmbeddedChatTokenBudgetState expected)
{
var tokens = new ConversationTokens { IsKnown = true, HistoryTokens = historyTokens, Window = ContextWindow.Of(WINDOW) };
Assert.That(EmbeddedChatTokenBudget.Of(tokens), Is.EqualTo(expected));
}
[Test]
public void TheMessageBeingWrittenCountsAsWell()
{
var tokens = new ConversationTokens { IsKnown = true, HistoryTokens = 20_000, DraftTokens = 6_000, Window = ContextWindow.Of(WINDOW) };
Assert.That(EmbeddedChatTokenBudget.Of(tokens), Is.EqualTo(EmbeddedChatTokenBudgetState.WARNING));
}
}
@@ -26,6 +26,12 @@ public sealed class InMemoryEmbeddedChatStore : IEmbeddedChatStore
/// <param name="chatId">The ID of the chat.</param>
public IReadOnlyList<ContentBlock> StoredBlocks(Guid chatId) => this.threads.TryGetValue(chatId, out var thread) ? thread.Blocks : [];
/// <summary>
/// Tells whether a chat is stored.
/// </summary>
/// <param name="chatId">The ID of the chat.</param>
public bool IsStored(Guid chatId) => this.threads.ContainsKey(chatId);
/// <inheritdoc />
public Task<ChatThread?> LoadThreadAsync(Guid chatId, CancellationToken token = default)
{
@@ -43,4 +49,11 @@ public sealed class InMemoryEmbeddedChatStore : IEmbeddedChatStore
this.SaveCount++;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteThreadAsync(Guid chatId, CancellationToken token = default)
{
this.threads.Remove(chatId);
return Task.CompletedTask;
}
}