2026-10-28 13:28:30 +01:00
using System.Text.Json ;
2026-10-04 12:26:26 +02:00
using AIStudio.Chat ;
using AIStudio.Settings ;
2026-10-28 13:28:30 +01:00
using AIStudio.Tools ;
2026-10-04 12:26:26 +02:00
using AIStudio.Tools.Services ;
2026-11-12 19:57:28 +01:00
using AIStudio.Tools.ToolCallingSystem ;
2026-10-04 12:26:26 +02:00
using AIStudio.Tools.Web ;
using Microsoft.Extensions.Logging.Abstractions ;
namespace AIStudio.Tests.Chat ;
/// <summary>
/// Checks which web addresses count as given to the model in a chat.
/// </summary>
/// <remarks>
/// Read Web Page reads a web page only when its address was given to the model, as long as its
/// free address choice is off or a mailbox restricts the chat: in the system prompt, by the user,
/// in a document the user attached, or by a tool. An address the model wrote itself never counts,
/// its own answers included, because it could have put anything of the chat into it.
/// </remarks>
[TestFixture]
public sealed class ChatThreadWebAddressTests
{
private static readonly DateTimeOffset START = new ( 2026 , 10 , 2 , 9 , 0 , 0 , TimeSpan . Zero );
[Test]
public void AnAddressTheUserWroteIsGiven ()
{
var thread = Thread ( Block ( ChatRole . USER , "Please summarize https://example.org/report for me." , 1 ));
2026-11-01 20:32:16 +01:00
using ( Assert . EnterMultipleScope ())
2026-10-04 12:26:26 +02:00
{
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/report" )), Is . True );
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://EXAMPLE.org/report#summary" )), Is . True , "The server gets the same request." );
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/report?budget=2026" )), Is . False , "The query would carry something the user never wrote." );
2026-11-01 20:32:16 +01:00
}
2026-10-04 12:26:26 +02:00
}
[Test]
public void AnAddressWithoutTlsTheUserWroteIsGiven ()
{
// An internal service such as a simple Python web server for the results of a data science run:
var thread = Thread ( Block ( ChatRole . USER , "The results of run 17 are on http://10.20.30.40:8000/run-17/, please compare them with run 16." , 1 ));
2026-11-01 20:32:16 +01:00
using ( Assert . EnterMultipleScope ())
2026-10-04 12:26:26 +02:00
{
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "http://10.20.30.40:8000/run-17/" )), Is . True );
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "http://10.20.30.40:8000/run-16/" )), Is . False , "The user named run 16, but not its address." );
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://10.20.30.40:8000/run-17/" )), Is . False , "Another scheme is another request." );
2026-11-01 20:32:16 +01:00
}
2026-10-04 12:26:26 +02:00
}
[Test]
public void AnAddressInAHiddenMessageOfTheUserIsGiven ()
{
// The prompt an assistant sends into a chat on behalf of the user:
var thread = Thread ( Block ( ChatRole . USER , "Read https://example.org/handbook and answer." , 1 , hidden : true ));
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/handbook" )), Is . True );
}
[Test]
public void AnAddressInAnAnswerOfTheModelIsNotGiven ()
{
var thread = Thread (
Block ( ChatRole . USER , "Where can I read more?" , 1 ),
Block ( ChatRole . AI , "You can read more at https://example.org/more." , 2 ));
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/more" )), Is . False , "The model wrote it, and it could have written a mail into it as well." );
}
[Test]
public void AnAddressAToolReturnedIsGiven ()
{
var thread = Thread ( Block ( ChatRole . USER , "What does the newsletter link to?" , 1 ));
thread . RuntimeWebAddressesFromTools . Add ( WebAddresses . CreateRequestKey ( new Uri ( "https://example.org/newsletter" )));
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/newsletter" )), Is . True );
}
2026-11-12 19:57:28 +01:00
[Test]
public void AnAddressAToolReturnedForAnAnswerOutlastsARestart ()
{
var answer = Answer ( "The newsletter links to the program at https://example.org/program and nothing else." , "https://example.org/program" );
var thread = Thread (
Block ( ChatRole . USER , "What does the newsletter link to?" , 1 ),
new ContentBlock { Time = START . AddMinutes ( 2 ), ContentType = ContentType . TEXT , Content = answer , Role = ChatRole . AI });
var loaded = JsonSerializer . Deserialize < ChatThread >( JsonSerializer . Serialize ( thread , WorkspaceBehaviour . JSON_OPTIONS ), WorkspaceBehaviour . JSON_OPTIONS )!;
using ( Assert . EnterMultipleScope ())
{
Assert . That ( loaded . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/program" )), Is . True , "A tool returned it, so the model neither made it up nor wrote anything of the chat into it." );
Assert . That ( loaded . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/program?mail=secret" )), Is . False , "Only the address the tool returned counts." );
}
}
[Test]
public void AnAnswerKeepsOnlyTheAddressesALaterMessageShows ()
{
var answer = new ContentText
{
Text = "The newsletter links to the program at https://example.org/program and nothing else." ,
Sources = [ new Source ( "Speakers" , "https://example.org/speakers" , SourceOrigin . TOOL )],
ToolInvocations =
[
new ToolInvocationTrace { Order = 1, ToolId = "read_web_page", Status = ToolInvocationTraceStatus.SUCCESS, WasExecuted = true, Arguments = new() { ["url"] = "https://example.org/newsletter" }, ArgumentsJson = """{" url ":" https : //example.org/newsletter"}""" },
new ToolInvocationTrace { Order = 2 , ToolId = "read_web_page" , Status = ToolInvocationTraceStatus . BLOCKED , Arguments = new () { [ "url" ] = "https://example.org/blocked" }, ArgumentsJson = """{" url ":" https : //example.org/blocked"}""" },
],
};
answer . KeepWebAddressesFromTools ( new [] { "program" , "speakers" , "newsletter" , "blocked" , "imprint" }. Select ( Key ));
answer . EndToolRun ();
Assert . That ( answer . WebAddressesFromTools , Is . EquivalentTo ( new [] { "program" , "speakers" , "newsletter" }. Select ( Key )), "In the text, as a source, and as the argument of a call which ran. A link the model no longer sees would only make the chat grow." );
}
[Test]
public void TheAddressesOfARunningAnswerAreNotStored ()
{
var answer = new ContentText { Text = "Let me read https://example.org/program first." };
answer . KeepWebAddressesFromTools ([ Key ( "program" )]);
// A chat is saved while its answer is still being written, and would hold every link of every page read until then:
var stored = JsonSerializer . Serialize ( Thread ( new ContentBlock { Time = START , ContentType = ContentType . TEXT , Content = answer , Role = ChatRole . AI }), WorkspaceBehaviour . JSON_OPTIONS );
var loaded = JsonSerializer . Deserialize < ChatThread >( stored , WorkspaceBehaviour . JSON_OPTIONS )!;
Assert . That ((( ContentText ) loaded . Blocks . Single (). Content !). WebAddressesFromTools , Is . Empty );
}
[Test]
public void EndingTheRunOnceMoreKeepsWhatTheFirstEndKept ()
{
var answer = Answer ( "The newsletter links to the program at https://example.org/program and nothing else." , "https://example.org/program" );
// Whoever ends a request ends the tool run as well, after the answer did:
answer . EndToolRun ();
Assert . That ( answer . WebAddressesFromTools , Has . Count . EqualTo ( 1 ));
}
[Test]
public void AnAddressAToolReturnedForADeletedAnswerIsNoLongerGiven ()
{
// A chat opened anew, whose runtime set is empty, so only what the answers store counts:
var answer = Answer ( "The newsletter links to the program at https://example.org/program and nothing else." , "https://example.org/program" );
var thread = Thread (
Block ( ChatRole . USER , "What does the newsletter link to?" , 1 ),
new ContentBlock { Time = START . AddMinutes ( 2 ), ContentType = ContentType . TEXT , Content = answer , Role = ChatRole . AI });
thread . Remove ( answer );
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/program" )), Is . False );
}
[Test]
public void AnAnswerGeneratedAnewForgetsTheAddressesOfItsEarlierToolRun ()
{
var answer = Answer ( "The newsletter links to the program at https://example.org/program and nothing else." , "https://example.org/program" );
var thread = Thread (
Block ( ChatRole . USER , "What does the newsletter link to?" , 1 ),
new ContentBlock { Time = START . AddMinutes ( 2 ), ContentType = ContentType . TEXT , Content = answer , Role = ChatRole . AI });
answer . BeginToolRun ();
using ( Assert . EnterMultipleScope ())
{
Assert . That ( answer . WebAddressesFromTools , Is . Empty );
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/program" )), Is . False , "Only the tools of the run which wrote the answer count for it." );
}
}
[Test]
public void ACopyOfAnAnswerKeepsItsAddressesApart ()
{
var answer = Answer ( "The newsletter links to the program at https://example.org/program and nothing else." , "https://example.org/program" );
var copy = ( ContentText ) answer . DeepClone ();
copy . WebAddressesFromTools . Add ( WebAddresses . CreateRequestKey ( new Uri ( "https://example.org/speakers" )));
using ( Assert . EnterMultipleScope ())
{
Assert . That ( copy . WebAddressesFromTools , Does . Contain ( WebAddresses . CreateRequestKey ( new Uri ( "https://example.org/program" ))), "A tool returned it in the conversation the copy continues." );
Assert . That ( answer . WebAddressesFromTools , Has . Count . EqualTo ( 1 ), "What the copy learns must not reach the answer it was copied from." );
}
}
[Test]
public void AnAddressAToolReturnedForAMessageOfTheUserIsNotGiven ()
{
// Only answers of the model are filled by the tool calling loop. A message of the user holding the set came from elsewhere:
var message = new ContentText { Text = "Hello." , WebAddressesFromTools = [ WebAddresses . CreateRequestKey ( new Uri ( "https://example.org/chosen" ))] };
var thread = Thread ( new ContentBlock { Time = START , ContentType = ContentType . TEXT , Content = message , Role = ChatRole . USER });
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/chosen" )), Is . False );
}
2026-10-04 12:26:26 +02:00
[Test]
public void AnAddressInTheSystemPromptIsGivenOnceTheRequestWasPrepared ()
{
// A chat template written by the user, and the content of a data source the RAG process found:
var thread = Thread ( Block ( ChatRole . USER , "Where are the travel rules?" , 1 ));
thread . SystemPrompt = "You answer questions about our handbook at https://handbook.example.org/." ;
thread . AugmentedData = "The travel rules stand on https://intranet.example.org/travel." ;
using var rustService = new RustService ( "1" , "unused" );
thread . PrepareSystemPrompt ( new SettingsManager ( NullLogger < SettingsManager >. Instance , rustService ));
2026-11-01 20:32:16 +01:00
using ( Assert . EnterMultipleScope ())
2026-10-04 12:26:26 +02:00
{
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://handbook.example.org/" )), Is . True );
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://intranet.example.org/travel" )), Is . True , "The RAG process found it in a data source, the model did not choose it." );
2026-11-01 20:32:16 +01:00
}
2026-10-04 12:26:26 +02:00
}
[Test]
public void AnAddressInADocumentTheUserAttachedIsGiven ()
{
var message = new ContentText
{
Text = "Please check the links in the attached report." ,
RuntimeAttachmentWebAddresses = new HashSet < string >( StringComparer . Ordinal ) { WebAddresses . CreateRequestKey ( new Uri ( "https://example.org/sources/2026" )) },
};
var thread = Thread ( new ContentBlock { Time = START , ContentType = ContentType . TEXT , Content = message , Role = ChatRole . USER });
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/sources/2026" )), Is . True , "The document is read only when the message is sent, which is when its addresses are collected." );
}
[Test]
public void AnAddressInADocumentOfTheModelIsNotGiven ()
{
var answer = new ContentText
{
Text = "Here is a summary." ,
RuntimeAttachmentWebAddresses = new HashSet < string >( StringComparer . Ordinal ) { WebAddresses . CreateRequestKey ( new Uri ( "https://example.org/chosen" )) },
};
var thread = Thread ( new ContentBlock { Time = START , ContentType = ContentType . TEXT , Content = answer , Role = ChatRole . AI });
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/chosen" )), Is . False );
}
2026-10-28 13:28:30 +01:00
[Test]
public void AnAddressAModelWroteIsNotGivenInAMessageOfTheUser ()
{
// An answer sent here from another chat, which the user then sent as a message of their own:
var thread = Thread ( Block ( ChatRole . USER , "Make slides of this: the details are on https://example.org/chosen, and our handbook is on https://example.org/handbook." , 1 ));
thread . RequireRestrictions ( ChatRestrictions . NONE . WithWebAddressesWrittenByModelIn ( "The details are on https://example.org/chosen." ));
2026-11-01 20:32:16 +01:00
using ( Assert . EnterMultipleScope ())
2026-10-28 13:28:30 +01:00
{
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/chosen" )), Is . False , "A model wrote it, so it could carry anything of the chat it came from." );
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/handbook" )), Is . True , "Another address in the same message still counts." );
2026-11-01 20:32:16 +01:00
}
2026-10-28 13:28:30 +01:00
}
[Test]
public void AnAddressAModelWroteIsGivenWhenAToolReturnedItOrADocumentHoldsIt ()
{
var attachedDocument = new ContentText
{
Text = "Make slides of this: the details are on https://example.org/chosen." ,
RuntimeAttachmentWebAddresses = new HashSet < string >( StringComparer . Ordinal ) { WebAddresses . CreateRequestKey ( new Uri ( "https://example.org/sources/2026" )) },
};
var thread = Thread ( new ContentBlock { Time = START , ContentType = ContentType . TEXT , Content = attachedDocument , Role = ChatRole . USER });
thread . RequireRestrictions ( ChatRestrictions . NONE . WithWebAddressesWrittenByModelIn ( "See https://example.org/chosen and https://example.org/sources/2026." ));
thread . RuntimeWebAddressesFromTools . Add ( WebAddresses . CreateRequestKey ( new Uri ( "https://example.org/chosen" )));
2026-11-01 20:32:16 +01:00
using ( Assert . EnterMultipleScope ())
2026-10-28 13:28:30 +01:00
{
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/chosen" )), Is . True , "A tool returned it in this chat, so it was not only a model which wrote it." );
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/sources/2026" )), Is . True , "A document the user attached holds it." );
2026-11-01 20:32:16 +01:00
}
2026-10-28 13:28:30 +01:00
}
[Test]
public void TheAddressesAModelWroteOutlastARestart ()
{
var thread = Thread ( Block ( ChatRole . USER , "Make slides of this: the details are on https://example.org/chosen." , 1 ));
thread . RequireRestrictions ( ChatRestrictions . NONE . WithWebAddressesWrittenByModelIn ( "The details are on https://example.org/chosen." ));
var loaded = JsonSerializer . Deserialize < ChatThread >( JsonSerializer . Serialize ( thread , WorkspaceBehaviour . JSON_OPTIONS ), WorkspaceBehaviour . JSON_OPTIONS )!;
Assert . That ( loaded . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/chosen" )), Is . False , "Forgetting them would let the chat know more addresses after a restart, not fewer." );
}
[Test]
public void ACopyOfAChatKeepsTheAddressesOfTheOtherApart ()
{
var thread = Thread ( Block ( ChatRole . USER , "Read https://example.org/chosen." , 1 ));
var copy = thread with { };
copy . RequireRestrictions ( ChatRestrictions . NONE . WithWebAddressesWrittenByModelIn ( "See https://example.org/chosen." ));
2026-11-01 20:32:16 +01:00
using ( Assert . EnterMultipleScope ())
2026-10-28 13:28:30 +01:00
{
Assert . That ( copy . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/chosen" )), Is . False );
Assert . That ( thread . IsWebAddressGivenToTheModel ( new Uri ( "https://example.org/chosen" )), Is . True , "What the copy took over must not reach the chat it was copied from." );
2026-11-01 20:32:16 +01:00
}
2026-10-28 13:28:30 +01:00
}
2026-10-04 12:26:26 +02:00
private static ChatThread Thread ( params ContentBlock [] blocks ) => new () { Blocks = [.. blocks ] };
private static ContentBlock Block ( ChatRole role , string text , int minute , bool hidden = false ) => new ()
{
Time = START . AddMinutes ( minute ),
ContentType = ContentType . TEXT ,
Content = new ContentText { Text = text },
Role = role ,
HideFromUser = hidden ,
};
2026-11-12 19:57:28 +01:00
/// <summary>
/// An answer whose tools returned some addresses, after its request ended.
/// </summary>
/// <param name="text">The text of the answer.</param>
/// <param name="returnedAddresses">What the tools returned.</param>
private static ContentText Answer ( string text , params string [] returnedAddresses )
{
var answer = new ContentText { Text = text };
answer . KeepWebAddressesFromTools ( returnedAddresses . Select ( address => WebAddresses . CreateRequestKey ( new Uri ( address ))));
answer . EndToolRun ();
return answer ;
}
private static string Key ( string path ) => WebAddresses . CreateRequestKey ( new Uri ( $"https://example.org/{path}" ));
2026-10-04 12:26:26 +02:00
}