2026-09-14 19:54:23 +02:00
using System.Text.Json ;
2026-09-13 14:17:25 +02:00
using AIStudio.Chat ;
2026-09-14 19:54:23 +02:00
using AIStudio.Tools.ToolCallingSystem ;
2026-09-13 14:17:25 +02:00
namespace AIStudio.Tests.Chat ;
/// <summary>
/// Checks what a conversation is counted as costing before it is sent.
/// </summary>
/// <remarks>
/// The number under the input field used to count the sentence being typed and nothing else, which
/// answers a question nobody asks: what decides whether the next message fits is everything that
/// travels with it. So what is collected here has to be what the message builder actually sends --
/// no more, because a number which counts something that stays behind is wrong in the direction
/// that makes a person stop writing.
2026-09-14 19:54:23 +02:00
///
/// Beyond the messages, a request carries the schema of every tool the model may call, and, while
/// it runs, everything those tools have returned so far. Both are invisible on the screen, and the
/// second one is where a window fills up fastest.
2026-09-13 14:17:25 +02:00
/// </remarks>
[TestFixture]
public sealed class ConversationPartsTests
{
private string directory = string . Empty ;
[SetUp]
public void CreateFiles ()
{
this . directory = Path . Combine ( Path . GetTempPath (), $"ai-studio-tests-{Guid.NewGuid():N}" );
Directory . CreateDirectory ( this . directory );
}
[TearDown]
public void RemoveFiles ()
{
if ( Directory . Exists ( this . directory ))
Directory . Delete ( this . directory , true );
}
[Test]
public void TheWholeConversationCountsAndNotOnlyWhatIsBeingTyped ()
{
var thread = new ChatThread
{
SystemPrompt = "You are helpful." ,
Blocks =
[
Block("What is the capital of France?"),
Block("Paris."),
] ,
};
2026-09-14 19:54:23 +02:00
var parts = ConversationParts . Of ( thread , "You are helpful." , "And of Italy?" , null , imagesAreSent : true , toolDefinitions : null );
2026-09-13 14:17:25 +02:00
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?" }));
});
}
[Test]
public void WhatIsStillBeingWrittenIsKeptApartFromWhatStands ()
{
//
// Both cost the same and both are counted. They are kept apart because of what happens
// afterwards: a message which stands says the same thing forever and its count is worth
// remembering, while the answer being streamed is a different text three seconds later.
//
var streaming = Block ( "The answer so far" );
(( ContentText ) streaming . Content !). IsStreaming = true ;
var thread = new ChatThread { Blocks = [ Block ( "A question." ), streaming ] };
2026-09-14 19:54:23 +02:00
var parts = ConversationParts . Of ( thread , string . Empty , "a draft" , null , imagesAreSent : true , toolDefinitions : null );
2026-09-13 14:17:25 +02:00
Assert . Multiple (() =>
{
Assert . That ( parts . Texts , Is . EqualTo ( new [] { "A question." }));
Assert . That ( parts . GrowingTexts , Is . EqualTo ( new [] { "The answer so far" , "a draft" }));
});
}
[Test]
public void AnAnswerWhichIsFinishedStandsLikeAnyOtherMessage ()
{
var finished = Block ( "The whole answer." );
(( ContentText ) finished . Content !). IsStreaming = false ;
2026-09-14 19:54:23 +02:00
var parts = ConversationParts . Of ( new () { Blocks = [ finished ] }, string . Empty , string . Empty , null , imagesAreSent : true , toolDefinitions : null );
2026-09-13 14:17:25 +02:00
Assert . Multiple (() =>
{
Assert . That ( parts . Texts , Is . EqualTo ( new [] { "The whole answer." }));
Assert . That ( parts . GrowingTexts , Is . Empty );
});
}
[Test]
public void TheSystemPromptCountedIsTheOneWhichWouldBeSent ()
{
//
// Not the one standing in the thread. A chat template may replace it, retrieved data is
// appended to it, a profile adds a paragraph and the tool policy adds another -- and
// switching a profile while writing has to move the number, which it cannot do if the
// thread's own field is what gets counted.
//
var thread = new ChatThread { SystemPrompt = "What the person typed." };
2026-09-14 19:54:23 +02:00
var parts = ConversationParts . Of ( thread , "What the request carries." , string . Empty , null , imagesAreSent : true , toolDefinitions : null );
2026-09-13 14:17:25 +02:00
Assert . That ( parts . Texts , Is . EqualTo ( new [] { "What the request carries." }));
}
[Test]
public void ABlockHiddenFromTheUserStillCosts ()
{
//
// Hidden on the screen, not in the request: the message builder sends it like any other
// block, so its tokens are gone whether or not anybody can see where they went.
//
var hidden = Block ( "An instruction the user does not see." );
var thread = new ChatThread { Blocks = [ new () { ContentType = hidden . ContentType , Role = hidden . Role , Content = hidden . Content , HideFromUser = true }] };
2026-09-14 19:54:23 +02:00
var parts = ConversationParts . Of ( thread , string . Empty , string . Empty , null , imagesAreSent : true , toolDefinitions : null );
2026-09-13 14:17:25 +02:00
Assert . That ( parts . Texts , Is . EqualTo ( new [] { "An instruction the user does not see." }));
}
[Test]
public void WithoutAConversationOnlyTheDraftCounts ()
{
2026-09-14 19:54:23 +02:00
var parts = ConversationParts . Of ( null , string . Empty , "Hello" , null , imagesAreSent : true , toolDefinitions : null );
2026-09-13 14:17:25 +02:00
Assert . Multiple (() =>
{
Assert . That ( parts . Texts , Is . Empty );
Assert . That ( parts . GrowingTexts , Is . EqualTo ( new [] { "Hello" }));
});
}
[TestCase("")]
[TestCase(" ")]
public void NothingWrittenIsNothingToCount ( string draft )
{
2026-09-14 19:54:23 +02:00
var parts = ConversationParts . Of ( null , string . Empty , draft , null , imagesAreSent : true , toolDefinitions : null );
2026-09-13 14:17:25 +02:00
Assert . Multiple (() =>
{
Assert . That ( parts . Texts , Is . Empty );
Assert . That ( parts . GrowingTexts , Is . Empty );
});
}
[Test]
public void ABlockWithoutTextIsSkippedBecauseItIsNeverSent ()
{
//
// The message builder drops a block whose text is empty, whatever else hangs off it. A
// count which added that block's attachments would report tokens for a message which is
// never built.
//
var document = this . WriteFile ( "notes.txt" , "some content" );
var empty = Block ( string . Empty );
(( ContentText ) empty . Content !). FileAttachments . Add ( FileAttachment . FromPath ( document ));
2026-09-14 19:54:23 +02:00
var parts = ConversationParts . Of ( new () { Blocks = [ empty ] }, string . Empty , string . Empty , null , imagesAreSent : true , toolDefinitions : null );
2026-09-13 14:17:25 +02:00
Assert . Multiple (() =>
{
Assert . That ( parts . Texts , Is . Empty );
Assert . That ( parts . Documents , Is . Empty );
});
}
[Test]
public void AttachmentsOfTheConversationAndOfTheComposerBothCount ()
{
//
// A document attached three messages ago is sent again with every further message, so it
// costs its tokens again every time. That is exactly the thing a person cannot see and
// which this number is for.
//
var older = this . WriteFile ( "older.txt" , "older content" );
var draft = this . WriteFile ( "draft.txt" , "draft content" );
var block = Block ( "Please read this." );
(( ContentText ) block . Content !). FileAttachments . Add ( FileAttachment . FromPath ( older ));
2026-09-14 19:54:23 +02:00
var parts = ConversationParts . Of ( new () { Blocks = [ block ] }, string . Empty , "And this one." , [ FileAttachment . FromPath ( draft )], imagesAreSent : true , toolDefinitions : null );
2026-09-13 14:17:25 +02:00
Assert . That ( parts . Documents . Select ( document => document . FileName ), Is . EqualTo ( new [] { "older.txt" , "draft.txt" }));
}
[Test]
public void AnAttachmentWhoseFileIsGoneCountsForNothing ()
{
//
// It is not sent either: the message builder reports it as unavailable and leaves it out.
//
var attachment = FileAttachment . FromPath ( Path . Combine ( this . directory , "never-existed.txt" ));
2026-09-14 19:54:23 +02:00
var parts = ConversationParts . Of ( null , string . Empty , "Here" , [ attachment ], imagesAreSent : true , toolDefinitions : null );
2026-09-13 14:17:25 +02:00
Assert . That ( parts . Documents , Is . Empty );
}
[Test]
public void ImagesAreCountedSeparatelyFromDocuments ()
{
var document = this . WriteFile ( "notes.txt" , "content" );
var image = this . WriteFile ( "photo.png" , "not really a png" );
2026-09-14 19:54:23 +02:00
var parts = ConversationParts . Of ( null , string . Empty , "Look" , [ FileAttachment . FromPath ( document ), FileAttachment . FromPath ( image )], imagesAreSent : true , toolDefinitions : null );
2026-09-13 14:17:25 +02:00
Assert . Multiple (() =>
{
Assert . That ( parts . Documents . Select ( entry => entry . FileName ), Is . EqualTo ( new [] { "notes.txt" }));
Assert . That ( parts . Images , Is . EqualTo ( 1 ));
});
}
[Test]
public void AModelWhichTakesNoImagesIsSentNoneAndIsToldAboutNone ()
{
//
// The message builder leaves the pictures out entirely for such a model, so reporting them
// as uncounted would tell a person about a cost which is not there.
//
var image = this . WriteFile ( "photo.png" , "not really a png" );
2026-09-14 19:54:23 +02:00
var parts = ConversationParts . Of ( null , string . Empty , "Look" , [ FileAttachment . FromPath ( image )], imagesAreSent : false , toolDefinitions : null );
2026-09-13 14:17:25 +02:00
Assert . That ( parts . Images , Is . Zero );
}
2026-09-14 19:54:23 +02:00
[Test]
public void ABlockWithoutTextCountsWhileItsToolsAreStillRunning ()
{
//
// While a model calls tools there is no text yet: the answer arrives in one piece at the
// end, and everything in between travels with every further round of the same request. The
// block which looks emptiest is therefore the one whose request is growing the fastest --
// and the one which used to be skipped for having nothing to say.
//
var running = Block ( string . Empty );
(( ContentText ) running . Content !). PendingToolConversation = [ "What the web search found." , "What the page said." ];
var parts = ConversationParts . Of ( new () { Blocks = [ running ] }, string . Empty , string . Empty , null , imagesAreSent : true , toolDefinitions : null );
Assert . Multiple (() =>
{
Assert . That ( parts . Texts , Is . Empty );
Assert . That ( parts . GrowingTexts , Is . EqualTo ( new [] { "What the web search found." , "What the page said." }));
});
}
[Test]
public void TwoToolResultsWhichReadTheSameCostTwice ()
{
//
// The request carries both, so both are paid for. Folding them into one would promise a
// smaller request than the one which is sent -- and a model reading the same page twice is
// not a rare accident but a thing that happens on any busy search.
//
var running = Block ( string . Empty );
(( ContentText ) running . Content !). PendingToolConversation = [ "The same page." , "The same page." ];
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." }));
}
[Test]
public void OnceTheAnswerStandsTheToolConversationIsGone ()
{
//
// It travels with the rounds of one request and with nothing afterwards: the next request is
// built from the messages alone. A number which kept counting it would report a window
// fuller than it is, and would never fall back.
//
var answered = Block ( "Here is what I found." );
var content = ( ContentText ) answered . Content !;
content . PendingToolConversation = [ "What the web search found." ];
content . EndToolRun ();
var parts = ConversationParts . Of ( new () { Blocks = [ answered ] }, string . Empty , string . Empty , null , imagesAreSent : true , toolDefinitions : null );
Assert . Multiple (() =>
{
Assert . That ( parts . Texts , Is . EqualTo ( new [] { "Here is what I found." }));
Assert . That ( parts . GrowingTexts , Is . Empty );
});
}
[Test]
public void TheToolSchemasCountAndTheyCountWithWhatStands ()
{
//
// Every request carries the schema of every offered tool, whether or not the model calls a
// single one of them. They belong with the lasting texts: a schema is the same string all
// session long, so its count is worth remembering.
//
var parts = ConversationParts . Of ( null , string . Empty , string . Empty , null , imagesAreSent : true , toolDefinitions :
[
Tool("web_search", "Searches the web.", """{"type":"object"}"""),
Tool("read_web_page", "Reads one page.", """{"type":"string"}"""),
] );
Assert . Multiple (() =>
{
Assert . That ( parts . Texts , Is . EqualTo ( new []
{
"""web_searchSearches the web.{" type ":" object "}""" ,
"""read_web_pageReads one page.{" type ":" string "}""" ,
}));
Assert . That ( parts . GrowingTexts , Is . Empty );
});
}
[Test]
public void AToolWhichStatesNoArgumentsCountsLikeAnyOther ()
{
//
// A definition which never names a parameter schema leaves an empty JSON element behind,
// and asking such an element for its text throws. A tool arriving from a plugin may well
// say nothing about its arguments, and the number under the input field is not the place
// to find that out.
//
var parts = ConversationParts . Of ( null , string . Empty , string . Empty , null , imagesAreSent : true , toolDefinitions :
[
new() { Function = new() { Name = "ping", DescriptionForLLM = "Says hello." } },
] );
Assert . That ( parts . Texts , Is . EqualTo ( new [] { "pingSays hello." }));
}
private static ToolDefinition Tool ( string name , string description , string parameterSchema ) => new ()
{
Function = new ()
{
Name = name ,
DescriptionForLLM = description ,
Parameters = JsonDocument . Parse ( parameterSchema ). RootElement . Clone (),
},
};
2026-09-13 14:17:25 +02:00
private static ContentBlock Block ( string text ) => new ()
{
ContentType = ContentType . TEXT ,
Role = ChatRole . USER ,
Content = new ContentText { Text = text },
};
private string WriteFile ( string name , string content )
{
var path = Path . Combine ( this . directory , name );
File . WriteAllText ( path , content );
return path ;
}
}