using AIStudio.Chat; namespace AIStudio.Tests.Chat; /// /// Checks when the token count is recomputed and when it is not. /// /// /// This is the part which kept going wrong. The number used to be wired to the places which change /// the conversation -- fifteen of them in the end -- and four review rounds each found another place /// which had been forgotten. So it is no longer wired to anything: whoever suspects a change nudges, /// and what is checked here is that the tracker turns those nudges into the right amount of work. /// /// The waits are generous on purpose. What is asserted is the behaviour, not the clock, so every /// interval here is far enough apart that a busy build machine cannot turn one into the other. /// [TestFixture] public sealed class ConversationTokenTrackerTests { /// /// A heartbeat which never fires, for the tests which are about nudges alone. /// private static readonly TimeSpan NO_HEARTBEAT = Timeout.InfiniteTimeSpan; [Test] public async Task ManyNudgesInARowCostOneCount() { // // Loading a chat touches several things one after the other, and every one of them renders. // Counting once per render would measure the same conversation half a dozen times. // var runs = 0; var firstRun = new TaskCompletionSource(); await using var tracker = new ConversationTokenTracker(_ => { Interlocked.Increment(ref runs); firstRun.TrySetResult(); return Task.CompletedTask; }, () => TimeSpan.FromSeconds(2), NO_HEARTBEAT); tracker.Start(); for (var i = 0; i < 50; i++) tracker.Nudge(); await firstRun.Task.WaitAsync(TimeSpan.FromSeconds(5)); await Task.Delay(200); Assert.That(runs, Is.EqualTo(1)); } [Test] public async Task ANudgeArrivingDuringACountLeadsToExactlyOneMore() { // // Something changed while we were reading, so the answer we just worked out may already be // out of date -- but only one further count can be needed, however many nudges arrived. // var runs = 0; var firstRunStarted = new TaskCompletionSource(); var releaseFirstRun = new TaskCompletionSource(); await using var tracker = new ConversationTokenTracker(async _ => { if (Interlocked.Increment(ref runs) is not 1) return; firstRunStarted.TrySetResult(); await releaseFirstRun.Task; }, () => TimeSpan.FromMilliseconds(100), NO_HEARTBEAT); tracker.Start(); tracker.Nudge(); await firstRunStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); // // Nudged while the first run is held up, five times over, because a render storm is what // this has to survive. // for (var i = 0; i < 5; i++) tracker.Nudge(); releaseFirstRun.SetResult(); await Task.Delay(1_000); Assert.That(runs, Is.EqualTo(2)); } [Test] public async Task WithoutAnyNudgeTheHeartbeatStillCounts() { // // For what happens outside AI Studio: an attached file somebody edits in another program // changes what the next message costs, and nothing here renders because of it. // var runs = 0; await using var tracker = new ConversationTokenTracker(_ => { Interlocked.Increment(ref runs); return Task.CompletedTask; }, () => TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(200)); tracker.Start(); await Task.Delay(1_000); Assert.That(runs, Is.GreaterThanOrEqualTo(2)); } [Test] public async Task TheQuietTimeIsAskedAnewAfterEveryRun() { // // Because the right answer changes with what is going on. Showing a new number renders, and // a render nudges, so while something moves continuously this delay is the entire cadence // -- and a chat which is waiting for a model wants a slower one than a chat which is not. // var runs = 0; var asked = 0; await using var tracker = new ConversationTokenTracker(_ => { Interlocked.Increment(ref runs); return Task.CompletedTask; }, () => { Interlocked.Increment(ref asked); return TimeSpan.FromMilliseconds(50); }, TimeSpan.FromMilliseconds(100)); tracker.Start(); await Task.Delay(1_000); Assert.Multiple(() => { Assert.That(runs, Is.GreaterThanOrEqualTo(2)); Assert.That(asked, Is.EqualTo(runs)); }); } [Test] public async Task NothingIsCountedAfterTheTrackerIsGone() { var runs = 0; var tracker = new ConversationTokenTracker(_ => { Interlocked.Increment(ref runs); return Task.CompletedTask; }, () => TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(100)); tracker.Start(); await Task.Delay(400); await tracker.DisposeAsync(); var afterDisposal = runs; await Task.Delay(400); Assert.Multiple(() => { Assert.That(afterDisposal, Is.GreaterThan(0), "The tracker never ran, so this proves nothing about stopping it."); Assert.That(runs, Is.EqualTo(afterDisposal)); }); } [Test] public async Task ACountWhichHangsDoesNotHoldUpDisposal() { // // Counting ends in an IPC call to the runtime, and a component going away must not wait for // one which is not coming back. The token handed to the work is the way out, and this is // the test that it really is one. // var running = new TaskCompletionSource(); var tracker = new ConversationTokenTracker(async token => { running.TrySetResult(); await Task.Delay(Timeout.InfiniteTimeSpan, token); }, () => TimeSpan.FromMilliseconds(50), NO_HEARTBEAT); tracker.Start(); tracker.Nudge(); await running.Task.WaitAsync(TimeSpan.FromSeconds(5)); var disposal = tracker.DisposeAsync().AsTask(); var finishedInTime = await Task.WhenAny(disposal, Task.Delay(TimeSpan.FromSeconds(2))) == disposal; Assert.That(finishedInTime, Is.True); } [Test] public async Task AFailedCountDoesNotEndTheTracker() { // // A tracker which died on one bad answer would leave a stale number standing forever, which // is the one failure this whole mechanism exists to rule out. // var runs = 0; await using var tracker = new ConversationTokenTracker(_ => { Interlocked.Increment(ref runs); throw new InvalidOperationException("The tokenizer did not answer."); }, () => TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(100)); tracker.Start(); await Task.Delay(1_000); Assert.That(runs, Is.GreaterThanOrEqualTo(2)); } }