diff --git a/app/MindWork AI Studio/Chat/ConversationParts.cs b/app/MindWork AI Studio/Chat/ConversationParts.cs
index f2010b55..bcb8e297 100644
--- a/app/MindWork AI Studio/Chat/ConversationParts.cs
+++ b/app/MindWork AI Studio/Chat/ConversationParts.cs
@@ -19,6 +19,11 @@ namespace AIStudio.Chat;
/// which is not about the next request but about the one in flight: it is what the model is
/// reading at this moment, it is what fills the window while somebody watches, and it is gone
/// again once the answer stands.
+///
+/// The three are kept apart, and nothing stands in two of them. The conversation so far is what a
+/// provider may already have counted exactly; the draft is what nobody has counted yet; and the
+/// tool conversation is what the number will lose again once the answer is there. Each of them is
+/// counted once and named on its own, so that a person can tell which part they are looking at.
///
public sealed record ConversationParts
{
@@ -28,54 +33,61 @@ public sealed record ConversationParts
public static readonly ConversationParts NOTHING = new();
///
- /// The texts which go into the request as they are.
+ /// The texts of the conversation which go into the request as they are.
///
public IReadOnlyList Texts { get; init; } = [];
///
- /// The texts which belong to this moment alone.
+ /// The texts of the conversation which are still being written.
///
///
/// They cost exactly what the others cost; what sets them apart is that they will never be seen
- /// again in this shape. The sentence somebody is typing changes with the next pause, and an
- /// answer being streamed is a different text three seconds later -- so remembering what they
- /// cost fills memory with answers nobody will ask for again.
- ///
- /// What a model's tools have returned so far belongs here for the same reason, although nobody
- /// is writing it: it travels with every further round of one request and with nothing after
- /// that, so it is measured while it matters and forgotten when the answer is there.
+ /// again in this shape. An answer being streamed is a different text three seconds later -- so
+ /// remembering what it cost fills memory with answers nobody will ask for again.
///
public IReadOnlyList GrowingTexts { get; init; } = [];
///
- /// The documents whose content is put into the request.
+ /// What the tools of the running request have returned so far, along with the calls to them.
+ ///
+ ///
+ /// Growing in the same way as the answer being streamed, and measured the same way. It travels
+ /// with every further round of one request and with nothing after that, so it is measured while
+ /// it matters and forgotten when the answer is there.
+ ///
+ public IReadOnlyList ToolConversation { get; init; } = [];
+
+ ///
+ /// The documents of the conversation whose content is put into the request.
///
public IReadOnlyList Documents { get; init; } = [];
///
- /// How many images travel along.
+ /// How many images of the conversation travel along.
///
public int Images { get; init; }
///
- /// Which of the texts above are the message being written right now.
+ /// What stands in the composer, or an empty string when nothing does.
///
///
- /// A marker, not a further part: everything named here also stands in GrowingTexts, and
- /// counting the parts counts each of them exactly once. It exists because the two halves of
- /// the number answer different questions. What the conversation has cost so far can be had
- /// exactly, from the provider which charged for it; what is about to be added to it can only be
- /// estimated. Told as one number, nobody can see which half they are looking at.
+ /// Changes with the next pause, so it is measured like the texts which are still being written.
///
- public IReadOnlyList DraftTexts { get; init; } = [];
+ public string DraftText { get; init; } = string.Empty;
///
- /// Which of the documents above are attached to the message being written right now.
+ /// The documents attached to the composer.
+ ///
+ public IReadOnlyList DraftDocuments { get; init; } = [];
+
+ ///
+ /// How many images attached to the composer travel along.
///
///
- /// A marker in the same way as DraftTexts: everything named here also stands in Documents.
+ /// Apart from the images of the conversation, because only those can be part of what a
+ /// provider has already counted.
///
- public IReadOnlyList DraftDocuments { get; init; } = [];
+ public int DraftImages { get; init; }
///
/// Collects what a conversation would send.
@@ -104,6 +116,7 @@ public sealed record ConversationParts
{
var texts = new List();
var growing = new List();
+ var toolConversation = new List();
var documents = new List();
var images = 0;
@@ -137,7 +150,7 @@ public sealed record ConversationParts
// the tool conversation. A block skipped for having nothing to say is exactly the
// block whose request is growing the fastest.
//
- growing.AddRange(text.PendingToolConversation);
+ toolConversation.AddRange(text.PendingToolConversation);
if (string.IsNullOrWhiteSpace(text.Text))
continue;
@@ -151,36 +164,24 @@ public sealed record ConversationParts
}
}
- var draftTexts = new List();
+ //
+ // Sorted the same way as the attachments of the conversation, into lists of their own.
+ //
var draftDocuments = new List();
-
- if (!string.IsNullOrWhiteSpace(draft))
- {
- growing.Add(draft);
- draftTexts.Add(draft);
- }
-
+ var draftImages = 0;
if (draftAttachments is not null)
- {
- var documentsBefore = documents.Count;
- Sort(draftAttachments, documents, ref images);
-
- //
- // Whatever sorting just appended is what the composer carries. Read off the list
- // rather than sorted a second time, so that a change to what counts as a document
- // cannot start meaning two different things in one method.
- //
- draftDocuments.AddRange(documents.Skip(documentsBefore));
- }
+ Sort(draftAttachments, draftDocuments, ref draftImages);
return new()
{
Texts = texts,
GrowingTexts = growing,
+ ToolConversation = toolConversation,
Documents = documents,
Images = imagesAreSent ? images : 0,
- DraftTexts = draftTexts,
+ DraftText = string.IsNullOrWhiteSpace(draft) ? string.Empty : draft,
DraftDocuments = draftDocuments,
+ DraftImages = imagesAreSent ? draftImages : 0,
};
}
diff --git a/app/MindWork AI Studio/Chat/ConversationTokens.cs b/app/MindWork AI Studio/Chat/ConversationTokens.cs
index b33ea015..a915d991 100644
--- a/app/MindWork AI Studio/Chat/ConversationTokens.cs
+++ b/app/MindWork AI Studio/Chat/ConversationTokens.cs
@@ -29,9 +29,25 @@ public readonly record struct ConversationTokens
public bool IsKnown { get; init; }
///
- /// How many tokens the counted parts of the conversation take.
+ /// How many tokens the counted parts of the conversation take, the draft included.
///
- public int Tokens { get; init; }
+ public int Tokens => this.HistoryTokens + this.DraftTokens;
+
+ ///
+ /// How many tokens the conversation takes without the message being written right now.
+ ///
+ public int HistoryTokens { get; init; }
+
+ ///
+ /// How much of HistoryTokens is what the tools of the running request returned so far.
+ ///
+ ///
+ /// Named on its own because it is the one share which goes away again. The model reads it in
+ /// every round of the request, so it fills the window while the tools work -- and none of it is
+ /// sent with the next message. Without saying so, the number would climb by tens of thousands
+ /// and then drop back once the answer stands, and nobody could tell why.
+ ///
+ public int ToolTokens { get; init; }
///
/// Whether the number is an estimate rather than the model's own count.
@@ -46,13 +62,12 @@ public readonly record struct ConversationTokens
public bool IsEstimate { get; init; }
///
- /// How much of Tokens is the message being written right now.
+ /// How many tokens the message being written right now takes.
///
///
/// Kept apart from the rest for the same reason the statements above are kept apart: what the
/// conversation has already cost is something a provider can be asked about, while a sentence
- /// nobody has sent yet can only be estimated. What the conversation costs without it is this
- /// subtracted from Tokens.
+ /// nobody has sent yet can only be estimated.
///
public int DraftTokens { get; init; }
@@ -75,7 +90,17 @@ public readonly record struct ConversationTokens
public ContextWindow Window { get; init; }
///
- /// How many images travel along which nobody can count.
+ /// How many images travel along, those of the conversation and those of the draft.
+ ///
+ public int Images { get; init; }
+
+ ///
+ /// How many of those images are attached to the message being written right now.
+ ///
+ public int DraftImages { get; init; }
+
+ ///
+ /// How many images travel along which nobody has counted.
///
///
/// Every vendor charges images differently -- OpenAI by tiles of the scaled image, Anthropic by
@@ -83,8 +108,11 @@ public readonly record struct ConversationTokens
/// file without decoding it first. So they are reported as a number of images instead of being
/// guessed at, or worse, counted as the base64 text they are sent as: that text is two to three
/// orders of magnitude longer than what any vendor charges for the picture.
+ ///
+ /// Where the provider reported the conversation so far, it counted the pictures in it as well,
+ /// however it charges them. Then only those of the draft are left uncounted.
///
- public int UncountedImages { get; init; }
+ public int UncountedImages => this.HistoryIsReported ? this.DraftImages : this.Images;
///
/// How many images the model takes, where its vendor stated a number.
@@ -101,7 +129,8 @@ public readonly record struct ConversationTokens
/// the person who crosses it has usually forgotten that the pictures are still there.
///
/// False whenever nobody stated a limit, which is most models. An invented ceiling would refuse
- /// something that works.
+ /// something that works. Whether anybody counted the pictures plays no part: the limit is on
+ /// how many travel, not on what they cost.
///
- public bool TooManyImages => this.ImageLimits.MaxInOneMessage is { } allowed && this.UncountedImages > allowed;
+ public bool TooManyImages => this.ImageLimits.MaxInOneMessage is { } allowed && this.Images > allowed;
}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs
index fbef15b4..8fa53013 100644
--- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs
+++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs
@@ -158,9 +158,11 @@ public partial class ChatComponent : MSGComponentBase
/// What the helper text under the input field says about the token budget.
///
///
- /// Four sentences rather than one built from pieces, because a translator needs to see the
- /// whole thing: which of the two numbers is the limit, and where the word for "about" belongs,
- /// are decisions no language makes the same way.
+ /// The number of the conversation and the window stand in four whole sentences, because a
+ /// translator needs to see the whole thing: which of the two numbers is the limit, and where the
+ /// word for "about" belongs, are decisions no language makes the same way. What follows -- the
+ /// tools' share, the draft, the pictures -- is added as clauses which are whole phrases in turn,
+ /// each with the one number it is about.
///
/// The images are named rather than counted. Every vendor charges a picture differently, and
/// none of those rules can be applied without decoding the file, so the honest answer is to say
@@ -174,34 +176,42 @@ public partial class ChatComponent : MSGComponentBase
return string.Empty;
//
- // Three statements, and which of them can be trusted differs. What the conversation
+ // Several statements, and which of them can be trusted differs. What the conversation
// has cost is exact wherever the provider said it; the window is whatever somebody
- // wrote down about the model; what is being written has never been sent and can only
- // ever be estimated. So the conversation and the draft are named apart, and the word
- // which marks a guess sits where the guess is.
+ // wrote down about the model; what the tools returned is only there while they work;
+ // what is being written has never been sent and can only ever be estimated. So they
+ // are named apart, and the word which marks a guess sits where the guess is.
//
- var history = TokenAmount.Format(this.conversationTokens.Tokens - this.conversationTokens.DraftTokens, this.currentCulture);
+ var history = TokenAmount.Format(this.conversationTokens.HistoryTokens, this.currentCulture);
var historyIsExact = this.conversationTokens.HistoryIsReported || !this.conversationTokens.IsEstimate;
var budget = this.conversationTokens.Window.IsKnown
? string.Format(historyIsExact ? this.T("{0} of {1} tokens") : this.T("approx. {0} of {1} tokens"), history, TokenAmount.Format(this.conversationTokens.Window.DefaultTokens, this.currentCulture))
: string.Format(historyIsExact ? this.T("{0} tokens") : this.T("approx. {0} tokens"), history);
+ //
+ // Said while the tools work, so that the number does not climb and fall back without a
+ // reason anybody could see: all of it is sent with every round of this request, and none
+ // of it with the next message.
+ //
+ if (this.conversationTokens.ToolTokens > 0)
+ budget = string.Format(this.T("{0}, of which approx. {1} from tool results"), budget, TokenAmount.Format(this.conversationTokens.ToolTokens, this.currentCulture));
+
if (this.conversationTokens.DraftTokens > 0)
budget = string.Format(this.T("{0}, plus approx. {1} for your message"), budget, TokenAmount.Format(this.conversationTokens.DraftTokens, this.currentCulture));
- if (this.conversationTokens.UncountedImages is 0)
- return budget;
-
//
// The pictures of the whole conversation, not of the message being written: every one
// of them is sent again with every further message, so a chat runs past the model's
- // limit long after anybody last thought about images.
+ // limit long after anybody last thought about images. That is worth saying even when the
+ // provider counted all of them.
//
- var images = this.conversationTokens.TooManyImages
- ? string.Format(this.T("plus {0} image(s), which is more than the {1} this model accepts"), this.conversationTokens.UncountedImages, this.conversationTokens.ImageLimits.MaxInOneMessage)
- : string.Format(this.T("plus {0} image(s), which cannot be counted"), this.conversationTokens.UncountedImages);
+ if (this.conversationTokens.TooManyImages)
+ return $"{budget} {string.Format(this.T("plus {0} image(s), which is more than the {1} this model accepts"), this.conversationTokens.Images, this.conversationTokens.ImageLimits.MaxInOneMessage)}";
- return $"{budget} {images}";
+ if (this.conversationTokens.UncountedImages is 0)
+ return budget;
+
+ return $"{budget} {string.Format(this.T("plus {0} image(s), which cannot be counted"), this.conversationTokens.UncountedImages)}";
}
}
diff --git a/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs b/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs
index 55437c18..e56ea12f 100644
--- a/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs
+++ b/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs
@@ -23,9 +23,10 @@ using Provider = AIStudio.Settings.Provider;
/// thousand-page PDF on each keystroke would be unusable. The conversation so far is remembered the
/// same way, so typing measures the sentence being typed rather than the whole chat again.
///
-/// The numbers are estimates and are shown as such. Unless somebody configured the model's own
-/// tokenizer for their provider, the built-in one does the counting, and two tokenizers disagree by
-/// a few percent on prose and by more than that on code.
+/// The numbers are estimates and are shown as such, unless a provider reported what the
+/// conversation so far cost. Unless somebody configured the model's own tokenizer for their
+/// provider, the built-in one does the counting, and two tokenizers disagree by a few percent on
+/// prose and by more than that on code.
///
public sealed class ConversationTokenCounter(RustService rustService, ILogger logger)
{
@@ -56,8 +57,8 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger
///
- /// One run's worth, replaced by the next -- so at most the draft and the answer being streamed
- /// stand in here. It exists for the case where nothing about them changed: a draft somebody left
+ /// One run's worth, replaced by the next -- so at most the draft, the answer being streamed, and
+ /// the tool conversation of the running request stand in here. It exists for the case where nothing about them changed: a draft somebody left
/// standing while they think would otherwise be measured again on every heartbeat, and that is a
/// call to the tokenizer for an answer we already have.
///
@@ -83,39 +84,47 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger(StringComparer.Ordinal);
- var tokens = 0;
- var reportedTokens = 0;
+ var historyTokens = 0;
+ var toolTokens = 0;
+ var draftTokens = 0;
try
{
- foreach (var text in parts.Texts)
- tokens += await this.CountTextAsync(provider, text, token);
-
- //
- // A text which is still being written is measured whole every time rather than by its
- // increment. Two counts meet at a token boundary, and adding up the pieces drifts
- // further from the truth with every three seconds an answer goes on.
- //
- foreach (var text in parts.GrowingTexts)
+ if (reported.IsKnown)
{
- var key = Key(provider, text);
- if (!previouslyGrowing.TryGetValue(key, out var known))
- known = await this.MeasureAsync(provider, text, token);
+ //
+ // Where a provider has said what this conversation cost, that number replaces the
+ // estimate of everything but the last answer. It is the exact one of the two, and
+ // it covers the same ground: the system prompt, the tools, every message which has
+ // been sent, and their attachments -- which is why none of those has to be read.
+ //
+ // The last answer is counted as the text it is sent as, not taken from the report:
+ // ReportedHistory says why the provider's number for it is the wrong one. It is a
+ // finished text of the conversation, so the cache mostly has it already.
+ //
+ historyTokens = reported.PromptTokens + await this.CountTextAsync(provider, reported.LastAnswer, token);
+ }
+ else
+ {
+ foreach (var text in parts.Texts)
+ historyTokens += await this.CountTextAsync(provider, text, token);
- growing[key] = known;
- tokens += known;
+ foreach (var text in parts.GrowingTexts)
+ historyTokens += await this.MeasureGrowingAsync(provider, text, previouslyGrowing, growing, token);
+
+ foreach (var text in parts.ToolConversation)
+ toolTokens += await this.MeasureGrowingAsync(provider, text, previouslyGrowing, growing, token);
+
+ foreach (var document in parts.Documents)
+ historyTokens += await this.CountDocumentAsync(provider, document, token);
+
+ // The tools' share is part of the conversation, and it is named as a share of it:
+ historyTokens += toolTokens;
}
- foreach (var document in parts.Documents)
- tokens += await this.CountDocumentAsync(provider, document, token);
-
- //
- // The last answer is counted as the text it is sent as, not taken from the report:
- // ReportedHistory says why the provider's number for it is the wrong one. It is a
- // finished text of the conversation, so the cache has it already.
- //
- if (reported.IsKnown)
- reportedTokens = reported.PromptTokens + await this.CountTextAsync(provider, reported.LastAnswer, token);
+ draftTokens = await this.MeasureGrowingAsync(provider, parts.DraftText, previouslyGrowing, growing, token);
+ foreach (var document in parts.DraftDocuments)
+ draftTokens += await this.CountDocumentAsync(provider, document, token);
}
catch (OperationCanceledException)
{
@@ -128,51 +137,48 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger
+ /// Measures a text which is still being written, unless it has not changed since the last count.
+ ///
+ ///
+ /// A text which is still being written is measured whole every time rather than by its
+ /// increment. Two counts meet at a token boundary, and adding up the pieces drifts further from
+ /// the truth with every three seconds an answer goes on.
+ ///
+ /// The configured provider, which decides the tokenizer.
+ /// The text to measure.
+ /// What the previous count measured.
+ /// What this count measured, for the next one.
+ /// Ends the counting when nobody needs the answer anymore.
+ /// The tokens of the text.
+ private async Task MeasureGrowingAsync(Provider provider, string text, IReadOnlyDictionary previouslyGrowing, Dictionary growing, CancellationToken token)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ return 0;
+
+ var key = Key(provider, text);
+ if (!previouslyGrowing.TryGetValue(key, out var known))
+ known = await this.MeasureAsync(provider, text, token);
+
+ growing[key] = known;
+ return known;
+ }
+
///
/// Forgets everything counted so far.
///
diff --git a/app/Tests/Chat/ConversationPartsTests.cs b/app/Tests/Chat/ConversationPartsTests.cs
index 8972be9a..132c21af 100644
--- a/app/Tests/Chat/ConversationPartsTests.cs
+++ b/app/Tests/Chat/ConversationPartsTests.cs
@@ -56,7 +56,8 @@ public sealed class ConversationPartsTests
Assert.Multiple(() =>
{
Assert.That(parts.Texts, Is.EqualTo(new[] { "You are helpful.", "What is the capital of France?", "Paris." }));
- Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "And of Italy?" }));
+ Assert.That(parts.DraftText, Is.EqualTo("And of Italy?"));
+ Assert.That(parts.GrowingTexts, Is.Empty, "The draft is a part of its own, and it stands nowhere else.");
});
}
@@ -77,7 +78,8 @@ public sealed class ConversationPartsTests
Assert.Multiple(() =>
{
Assert.That(parts.Texts, Is.EqualTo(new[] { "A question." }));
- Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "The answer so far", "a draft" }));
+ Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "The answer so far" }));
+ Assert.That(parts.DraftText, Is.EqualTo("a draft"));
});
}
@@ -135,7 +137,7 @@ public sealed class ConversationPartsTests
Assert.Multiple(() =>
{
Assert.That(parts.Texts, Is.Empty);
- Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "Hello" }));
+ Assert.That(parts.DraftText, Is.EqualTo("Hello"));
});
}
@@ -149,6 +151,7 @@ public sealed class ConversationPartsTests
{
Assert.That(parts.Texts, Is.Empty);
Assert.That(parts.GrowingTexts, Is.Empty);
+ Assert.That(parts.DraftText, Is.Empty);
});
}
@@ -188,7 +191,11 @@ public sealed class ConversationPartsTests
var parts = ConversationParts.Of(new() { Blocks = [block] }, string.Empty, "And this one.", [FileAttachment.FromPath(draft)], imagesAreSent: true, toolDefinitions: null);
- Assert.That(parts.Documents.Select(document => document.FileName), Is.EqualTo(new[] { "older.txt", "draft.txt" }));
+ Assert.Multiple(() =>
+ {
+ Assert.That(parts.Documents.Select(document => document.FileName), Is.EqualTo(new[] { "older.txt" }));
+ Assert.That(parts.DraftDocuments.Select(document => document.FileName), Is.EqualTo(new[] { "draft.txt" }), "Both count, each in the part it belongs to.");
+ });
}
[Test]
@@ -201,7 +208,7 @@ public sealed class ConversationPartsTests
var parts = ConversationParts.Of(null, string.Empty, "Here", [attachment], imagesAreSent: true, toolDefinitions: null);
- Assert.That(parts.Documents, Is.Empty);
+ Assert.That(parts.DraftDocuments, Is.Empty);
}
[Test]
@@ -214,8 +221,29 @@ public sealed class ConversationPartsTests
Assert.Multiple(() =>
{
- Assert.That(parts.Documents.Select(entry => entry.FileName), Is.EqualTo(new[] { "notes.txt" }));
+ Assert.That(parts.DraftDocuments.Select(entry => entry.FileName), Is.EqualTo(new[] { "notes.txt" }));
+ Assert.That(parts.DraftImages, Is.EqualTo(1));
+ });
+ }
+
+ [Test]
+ public void ImagesOfTheConversationAndOfTheDraftAreKeptApart()
+ {
+ //
+ // A provider which reported the conversation so far counted the pictures in it as well,
+ // but never the ones which are still waiting in the composer.
+ //
+ var sent = this.WriteFile("sent.png", "not really a png");
+ var waiting = this.WriteFile("waiting.png", "not really a png either");
+ var block = Block("Look at this.");
+ ((ContentText)block.Content!).FileAttachments.Add(FileAttachment.FromPath(sent));
+
+ var parts = ConversationParts.Of(new() { Blocks = [block] }, string.Empty, "And at this.", [FileAttachment.FromPath(waiting)], imagesAreSent: true, toolDefinitions: null);
+
+ Assert.Multiple(() =>
+ {
Assert.That(parts.Images, Is.EqualTo(1));
+ Assert.That(parts.DraftImages, Is.EqualTo(1));
});
}
@@ -230,7 +258,7 @@ public sealed class ConversationPartsTests
var parts = ConversationParts.Of(null, string.Empty, "Look", [FileAttachment.FromPath(image)], imagesAreSent: false, toolDefinitions: null);
- Assert.That(parts.Images, Is.Zero);
+ Assert.That(parts.DraftImages, Is.Zero);
}
[Test]
@@ -250,7 +278,8 @@ public sealed class ConversationPartsTests
Assert.Multiple(() =>
{
Assert.That(parts.Texts, Is.Empty);
- Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "What the web search found.", "What the page said." }));
+ Assert.That(parts.ToolConversation, Is.EqualTo(new[] { "What the web search found.", "What the page said." }));
+ Assert.That(parts.GrowingTexts, Is.Empty, "The tool conversation is a part of its own, so that its share can be named.");
});
}
@@ -267,7 +296,7 @@ public sealed class ConversationPartsTests
var parts = ConversationParts.Of(new() { Blocks = [running] }, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions: null);
- Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "The same page.", "The same page." }));
+ Assert.That(parts.ToolConversation, Is.EqualTo(new[] { "The same page.", "The same page." }));
}
[Test]
@@ -288,7 +317,7 @@ public sealed class ConversationPartsTests
Assert.Multiple(() =>
{
Assert.That(parts.Texts, Is.EqualTo(new[] { "Here is what I found." }));
- Assert.That(parts.GrowingTexts, Is.Empty);
+ Assert.That(parts.ToolConversation, Is.Empty);
});
}
diff --git a/app/Tests/Chat/ConversationTokensTests.cs b/app/Tests/Chat/ConversationTokensTests.cs
index 9cff6dde..4f1997ac 100644
--- a/app/Tests/Chat/ConversationTokensTests.cs
+++ b/app/Tests/Chat/ConversationTokensTests.cs
@@ -25,7 +25,7 @@ public sealed class ConversationTokensTests
var counted = new ConversationTokens
{
IsKnown = true,
- UncountedImages = images,
+ Images = images,
ImageLimits = new ImageLimits(null, allowed),
};
@@ -42,7 +42,7 @@ public sealed class ConversationTokensTests
var counted = new ConversationTokens
{
IsKnown = true,
- UncountedImages = 500,
+ Images = 500,
ImageLimits = ImageLimits.UNKNOWN,
};
@@ -60,7 +60,7 @@ public sealed class ConversationTokensTests
var counted = new ConversationTokens
{
IsKnown = true,
- UncountedImages = 20,
+ Images = 20,
ImageLimits = new ImageLimits(8, 100),
};
@@ -77,7 +77,7 @@ public sealed class ConversationTokensTests
var counted = new ConversationTokens
{
IsKnown = true,
- UncountedImages = 0,
+ Images = 0,
ImageLimits = new ImageLimits(null, 0),
};
@@ -93,4 +93,64 @@ public sealed class ConversationTokensTests
//
Assert.That(ConversationTokens.UNAVAILABLE.TooManyImages, Is.False);
}
+
+ [Test]
+ public void PicturesAProviderCountedAreNotCalledUncounted()
+ {
+ //
+ // What the provider reported for the conversation so far includes its pictures, however
+ // it charges them. Only those still waiting in the composer are left for nobody to count.
+ //
+ var reported = new ConversationTokens
+ {
+ IsKnown = true,
+ HistoryIsReported = true,
+ Images = 3,
+ DraftImages = 1,
+ };
+
+ var estimated = reported with { HistoryIsReported = false };
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(reported.UncountedImages, Is.EqualTo(1), "The provider counted the two which were sent.");
+ Assert.That(estimated.UncountedImages, Is.EqualTo(3), "Without a report, nobody counted any of them.");
+ });
+ }
+
+ [Test]
+ public void TooManyPicturesStaysTooManyWhenTheProviderCountedThem()
+ {
+ //
+ // The limit is on how many pictures travel, not on what they cost. A provider which has
+ // counted them still refuses the request which carries one too many.
+ //
+ var counted = new ConversationTokens
+ {
+ IsKnown = true,
+ HistoryIsReported = true,
+ Images = 10,
+ ImageLimits = new ImageLimits(8, null),
+ };
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(counted.UncountedImages, Is.Zero);
+ Assert.That(counted.TooManyImages, Is.True);
+ });
+ }
+
+ [Test]
+ public void TheWholeNumberIsTheConversationAndTheDraft()
+ {
+ var counted = new ConversationTokens
+ {
+ IsKnown = true,
+ HistoryTokens = 12_400,
+ ToolTokens = 9_000,
+ DraftTokens = 340,
+ };
+
+ Assert.That(counted.Tokens, Is.EqualTo(12_740), "The tools' share is part of the conversation, not added on top of it.");
+ }
}
\ No newline at end of file