2026-09-20 08:44:33 +00:00
using System.Runtime.CompilerServices ;
using AIStudio.Provider ;
using AIStudio.Tools ;
using AIStudio.Tools.ToolCallingSystem ;
using AIStudio.Tools.ToolCallingSystem.Harness ;
using Microsoft.Extensions.Logging.Abstractions ;
namespace AIStudio.Tests.Tools.ToolCalling ;
/// <summary>
/// Checks what the tool calling loop puts on screen while a model works through its tools.
/// </summary>
/// <remarks>
/// Two things decide whether this loop behaves: every word the model writes has to arrive, and it
/// has to arrive once. Both used to be free -- the round's text was shown at its end, and there
/// was nothing else it could have come from. Now the text streams out while the round runs and
/// the round still reports it afterwards, so the one thing that must never happen is showing it
/// twice. The other side of the same coin is the preamble a model writes before it calls a tool,
/// which was dropped entirely before and is the reason for this whole change.<br/><br/>
/// The adapter is scripted rather than real: what a provider puts on the wire is checked in the
/// accumulator tests, while this is about the loop in between.
/// </remarks>
[TestFixture]
public sealed class ToolCallingLoopTests
{
private const string PREAMBLE = "Let me look that up." ;
private const string ANSWER = "Here is the answer." ;
private const string SEPARATOR = "\n\n" ;
private const string NO_ANSWER = "did not return a final answer" ;
[Test]
public async Task APreambleReachesTheUserAlthoughItsRoundOnlyCalledATool ( )
{
//
// The regression this whole change is about: a model which says what it is about to do
// before it does it. That sentence never left the provider layer.
//
var adapter = new ScriptedAdapter (
[Text(PREAMBLE), Completed(PREAMBLE, [Call("call-1")] ) ] ,
[Text(ANSWER), Completed(ANSWER)] ) ;
var written = await Run ( adapter ) ;
Assert . That ( written , Does . StartWith ( PREAMBLE ) , "What the model says before it calls a tool is the first thing the user reads, not something we keep to ourselves." ) ;
}
[Test]
public async Task EveryTextIsWrittenExactlyOnce ( )
{
//
// The one way this can go wrong: the round reports the same text its deltas already
// carried, and the answer ends up on screen twice.
//
var adapter = new ScriptedAdapter (
[Text(PREAMBLE), Completed(PREAMBLE, [Call("call-1")] ) ] ,
[Text(ANSWER), Completed(ANSWER)] ) ;
var written = await Run ( adapter ) ;
Assert . Multiple ( ( ) = >
{
Assert . That ( Occurrences ( written , PREAMBLE ) , Is . EqualTo ( 1 ) , "The preamble streamed out; the round reporting it again must not put it on screen a second time." ) ;
Assert . That ( Occurrences ( written , ANSWER ) , Is . EqualTo ( 1 ) , "The same goes for the final answer, which is where a duplicate would be most visible." ) ;
} ) ;
}
[Test]
public async Task OnlyARoundWhichSpeaksGetsASeparator ( )
{
//
// A round which does nothing but call a tool must not leave a gap behind: the separator
// belongs between two texts, not after every round.
//
var afterSpeaking = await Run ( new ScriptedAdapter (
[Text(PREAMBLE), Completed(PREAMBLE, [Call("call-1")] ) ] ,
[Text(ANSWER), Completed(ANSWER)] ) ) ;
var afterSilence = await Run ( new ScriptedAdapter (
[Completed(string.Empty, [Call("call-1")] ) ] ,
[Text(ANSWER), Completed(ANSWER)] ) ) ;
Assert . Multiple ( ( ) = >
{
Assert . That ( afterSpeaking , Is . EqualTo ( $"{PREAMBLE}{SEPARATOR}{ANSWER}" ) , "Two texts from two rounds are two paragraphs, not one run-on sentence." ) ;
Assert . That ( afterSilence , Is . EqualTo ( ANSWER ) , "Nothing was said before, so there is nothing to separate from." ) ;
} ) ;
}
[Test]
public async Task TheLimitMessageOnlyAppearsWhenTheLastRoundSaidNothing ( )
{
//
// Reaching the limit means the model is asked for a final answer without tools. When it
// gives one, that answer has already streamed out -- and the message about not having
// answered has to stay away.
//
var answering = await Run ( new ScriptedAdapter ( [ . . ExhaustTheToolBudget ( ) , [ Text ( ANSWER ) , Completed ( ANSWER ) ] ] ) ) ;
var silent = await Run ( new ScriptedAdapter ( [ . . ExhaustTheToolBudget ( ) , [ Completed ( string . Empty ) ] ] ) ) ;
Assert . Multiple ( ( ) = >
{
Assert . That ( answering , Does . EndWith ( ANSWER ) . And . Not . Contains ( NO_ANSWER ) , "The model answered, so nothing has to be said on its behalf." ) ;
Assert . That ( silent , Does . Contain ( NO_ANSWER ) , "It stayed silent after using up its tools, and silence would look like a hung request." ) ;
} ) ;
}
[Test]
public async Task TheNoAnswerMessageOnlyAppearsWhenTheRoundSaidNothing ( )
{
var answering = await Run ( new ScriptedAdapter (
[Completed(string.Empty, [Call("call-1")] ) ] ,
[Text(ANSWER), Completed(ANSWER)] ) ) ;
var silent = await Run ( new ScriptedAdapter (
[Completed(string.Empty, [Call("call-1")] ) ] ,
[Completed(string.Empty)] ) ) ;
Assert . Multiple ( ( ) = >
{
Assert . That ( answering , Is . EqualTo ( ANSWER ) , "There is an answer, so the fallback message has no place here." ) ;
Assert . That ( silent , Does . Contain ( NO_ANSWER ) , "The tool ran and nothing came of it, which the user has to be told." ) ;
} ) ;
}
[Test]
public async Task TheSourcesArriveAlthoughTheFinalTextNoLongerDoes ( )
{
//
// The last round hands over an empty chunk carrying the sources, because its text went
// out as deltas. Forget that chunk and the citation links of a web search disappear.
//
var source = new Source ( "Example" , "https://example.org/" , SourceOrigin . LLM ) ;
var adapter = new ScriptedAdapter (
[Completed(string.Empty, [Call("call-1")] , [ source ] ) ] ,
[Text(ANSWER), Completed(ANSWER)] ) ;
var chunks = await Collect ( adapter ) ;
Assert . That ( chunks . SelectMany ( chunk = > chunk . Sources ) . Select ( x = > x . URL ) , Does . Contain ( "https://example.org/" ) , "The sources of a round reach the caller even when its text does not." ) ;
}
[Test]
public async Task ARoundWhichNeverCompletesEndsQuietly ( )
{
//
// A stream cut off mid-sentence, or a request which failed: the adapter has told the user
// what went wrong already, so the loop adds nothing of its own.
//
var adapter = new ScriptedAdapter ( [ Text ( PREAMBLE ) ] ) ;
var chunks = await Collect ( adapter ) ;
Assert . Multiple ( ( ) = >
{
Assert . That ( string . Concat ( chunks . Select ( x = > x . Content ) ) , Is . EqualTo ( PREAMBLE ) , "What was streamed stays; nothing is taken back." ) ;
Assert . That ( chunks . Select ( x = > x . Content ) , Has . None . Contains ( NO_ANSWER ) , "An error message on top of the adapter's own would say the same thing twice." ) ;
} ) ;
}
[Test]
public async Task ACallWithoutAnIdEndsTheConversation ( )
{
//
// The result is correlated by that ID. Inventing one has the next request rejected, so
// there is nothing to salvage from a round like this.
//
var written = await Run ( new ScriptedAdapter (
[Completed(string.Empty, [Call(string.Empty)] ) ] ,
[Text(ANSWER), Completed(ANSWER)] ) ) ;
Assert . Multiple ( ( ) = >
{
Assert . That ( written , Does . Contain ( "The tool call was invalid." ) , "The user learns why the answer stops here." ) ;
Assert . That ( written , Does . Not . Contain ( ANSWER ) , "And the loop does not carry on into a round the provider would refuse." ) ;
} ) ;
}
[Test]
public async Task TheModelsTurnIsRecordedOncePerRoundAndBeforeItsResults ( )
{
//
// The provider has to know about the turn before it is sent results for it, and recording
// it twice would send the same tool call twice.
//
var adapter = new ScriptedAdapter (
[Text(PREAMBLE), Completed(PREAMBLE, [Call("call-1"), Call("call-2")] ) ] ,
[Text(ANSWER), Completed(ANSWER)] ) ;
await Run ( adapter ) ;
Assert . That ( adapter . Recordings , Is . EqualTo ( new [ ] { "turn" , "result:call-1" , "result:call-2" } ) , "One turn, then its results, in the order the model asked for them." ) ;
}
[Test]
public async Task ACancelledStreamStopsTheLoopWhereItIs ( )
{
//
// What the user sees when they press stop. The provider's stream reader ends quietly on
// a cancellation rather than throwing, so the round reaches its end without completing --
// which has to leave the text alone and add nothing to it.
//
using var cancellation = new CancellationTokenSource ( ) ;
var adapter = new ScriptedAdapter ( [ Text ( PREAMBLE ) , Text ( ANSWER ) , Completed ( ANSWER ) ] )
{
CancelAfterFirstEvent = cancellation ,
} ;
var chunks = await Collect ( adapter , cancellation . Token ) ;
Assert . Multiple ( ( ) = >
{
Assert . That ( string . Concat ( chunks . Select ( x = > x . Content ) ) , Is . EqualTo ( PREAMBLE ) , "Everything written before the stop stays, and nothing after it arrives." ) ;
Assert . That ( chunks . Select ( x = > x . Content ) , Has . None . Contains ( NO_ANSWER ) , "A stop is not a failure to answer, so it is not reported as one." ) ;
} ) ;
}
2026-09-24 11:36:06 +00:00
[Test]
public async Task OnlyTheFirstRoundsUsageReachesTheAnswer ( )
{
//
// Every round is a request of its own, and every one of them reports what it cost. Only
// the first one describes what the next question will be sent after: every later round
// carries the tool calls and their results on top, none of which is sent again once the
// answer stands.
//
var adapter = new ScriptedAdapter (
[Usage(1200), Completed(string.Empty, [Call("call-1")] ) ] ,
[Text(ANSWER), Usage(9800), Completed(ANSWER)] ) ;
var usages = ( await Collect ( adapter ) ) . Where ( chunk = > chunk . Usage . IsKnown ) . Select ( chunk = > chunk . Usage . PromptTokens ) ;
Assert . That ( usages , Is . EqualTo ( new [ ] { 1200 } ) , "The second round's prompt holds the tool result as well, which the next question is not sent with." ) ;
}
[Test]
public async Task ALaterRoundsUsageLeavesItsTextAndNothingElse ( )
{
//
// Some providers send the usage next to the last piece of text rather than on a line of
// its own. Dropping the usage must not drop that text, and a delta which carried nothing
// but the usage must not turn into an empty chunk of its own.
//
var withUsage = await Collect ( new ScriptedAdapter (
[Completed(string.Empty, [Call("call-1")] ) ] ,
[Usage(9800), Usage(9800, ANSWER), Completed(ANSWER)] ) ) ;
var withoutUsage = await Collect ( new ScriptedAdapter (
[Completed(string.Empty, [Call("call-1")] ) ] ,
[Text(ANSWER), Completed(ANSWER)] ) ) ;
Assert . Multiple ( ( ) = >
{
Assert . That ( withUsage . Select ( x = > x . Content ) , Is . EqualTo ( withoutUsage . Select ( x = > x . Content ) ) , "The same chunks arrive as if the round had reported nothing." ) ;
Assert . That ( withUsage . Select ( x = > x . Usage . IsKnown ) , Has . None . True , "And none of them carries the usage on." ) ;
} ) ;
}
2026-09-20 08:44:33 +00:00
/// <summary>
/// As many rounds calling one tool each as it takes to use up the tool budget.
/// </summary>
private static List < IReadOnlyList < ToolCallingStreamEvent > > ExhaustTheToolBudget ( ) = > Enumerable
. Range ( 0 , ToolSelectionRules . MAX_TOOL_CALLS )
. Select ( IReadOnlyList < ToolCallingStreamEvent > ( round ) = > [ Completed ( string . Empty , [ Call ( $"call-{round}" ) ] ) ] )
. ToList ( ) ;
private static ToolCallingStreamEvent Text ( string text ) = > ToolCallingStreamEvent . TextDelta ( text ) ;
2026-09-24 11:36:06 +00:00
private static ToolCallingStreamEvent Usage ( int promptTokens , string text = "" ) = > ToolCallingStreamEvent . TextDelta ( new ContentStreamChunk ( text , [ ] , TokenUsage . Of ( promptTokens ) ) ) ;
2026-09-20 08:44:33 +00:00
private static ToolCallingStreamEvent Completed ( string text , IReadOnlyList < ToolCallingRequestedCall > ? calls = null , IReadOnlyList < ISource > ? sources = null )
= > ToolCallingStreamEvent . RoundCompleted ( new ToolCallingRound ( text , calls ? ? [ ] , sources ? ? [ ] ) ) ;
private static ToolCallingRequestedCall Call ( string callId ) = > new ( callId , "some_tool" , "{}" , true ) ;
private static async Task < string > Run ( ScriptedAdapter adapter ) = > string . Concat ( ( await Collect ( adapter ) ) . Select ( chunk = > chunk . Content ) ) ;
private static async Task < List < ContentStreamChunk > > Collect ( ScriptedAdapter adapter , CancellationToken token = default )
{
var loop = new ToolCallingLoop ( NullLogger < ToolCallingLoop > . Instance ) ;
var chunks = new List < ContentStreamChunk > ( ) ;
await foreach ( var chunk in loop . RunAsync ( adapter , CreateContext ( ) , token ) )
chunks . Add ( chunk ) ;
return chunks ;
}
/// <summary>
/// A context which needs nothing of the application around it.
/// </summary>
/// <remarks>
/// Without an assistant message, every UI call of the context returns right away, which is
/// what keeps the service provider out of these tests. The tool executor gets no settings
/// service for the same reason: with no runnable tools, every call ends as blocked long
/// before any setting is read.
/// </remarks>
private static ToolCallingLoopContext CreateContext ( ) = > new ( )
{
ChatThread = new ( ) ,
RunnableTools = [ ] ,
ToolExecutor = new ( null ! , NullLogger < ToolExecutor > . Instance ) ,
Provider = new NoProvider ( ) ,
CurrentAssistantContent = null ,
ProviderInstanceName = "Test provider" ,
ProviderType = LLMProviders . NONE ,
ModelId = "test-model" ,
} ;
private static int Occurrences ( string text , string part )
{
var count = 0 ;
for ( var index = text . IndexOf ( part , StringComparison . Ordinal ) ; index > = 0 ; index = text . IndexOf ( part , index + part . Length , StringComparison . Ordinal ) )
count + + ;
return count ;
}
/// <summary>
/// An adapter which plays back a script of events, one list per round.
/// </summary>
private sealed class ScriptedAdapter ( params IReadOnlyList < ToolCallingStreamEvent > [ ] rounds ) : IToolCallingProviderAdapter
{
private readonly Queue < IReadOnlyList < ToolCallingStreamEvent > > remainingRounds = new ( rounds ) ;
/// <summary>
/// When set, the run is cancelled right after the first event of the first round, the way
/// a user pressing stop cancels one.
/// </summary>
public CancellationTokenSource ? CancelAfterFirstEvent { get ; init ; }
/// <summary>
/// What the loop recorded, in the order it did.
/// </summary>
public List < string > Recordings { get ; } = [ ] ;
/// <inheritdoc />
public IReadOnlyList < string > RecordedRequestTexts = > [ ] ;
/// <inheritdoc />
public async IAsyncEnumerable < ToolCallingStreamEvent > ExecuteRoundAsync ( string? finalResponseInstruction , bool includeTools , [ EnumeratorCancellation ] CancellationToken token = default )
{
await Task . Yield ( ) ;
if ( this . remainingRounds . Count is 0 )
yield break ;
foreach ( var streamEvent in this . remainingRounds . Dequeue ( ) )
{
//
// Ending rather than throwing, which is what the shared stream reader does when a
// cancellation reaches it: it stops reading lines and lets the round end without
// its completed event.
//
if ( token . IsCancellationRequested )
yield break ;
yield return streamEvent ;
this . CancelAfterFirstEvent ? . Cancel ( ) ;
}
}
/// <inheritdoc />
public void RecordAssistantTurn ( ) = > this . Recordings . Add ( "turn" ) ;
/// <inheritdoc />
public void RecordToolResult ( string callId , string content , bool isError = false ) = > this . Recordings . Add ( $"result:{callId}" ) ;
}
}