Added profile, provider, and workspace preselection to chat templates (#1099)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Peer HogeterpandThorsten Sommer authored and GitHub committed 2026-10-22 14:15:10 +02:00
1 parent ab9df99cf0
commit 24c4c31035
34 files changed
+2084 -142

No files matched your search

@@ -118,6 +118,9 @@ public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
Assert.That(imported.DataSourceOptions!.AutomaticDataSourceSelection, Is.EqualTo(template.DataSourceOptions!.AutomaticDataSourceSelection));
Assert.That(imported.DataSourceOptions.AutomaticValidation, Is.EqualTo(template.DataSourceOptions.AutomaticValidation));
Assert.That(imported.DataSourceOptions.PreselectedDataSourceIds, Is.EqualTo(template.DataSourceOptions.PreselectedDataSourceIds));
Assert.That(imported.ProviderId, Is.EqualTo(template.ProviderId));
Assert.That(imported.ProfileId, Is.EqualTo(template.ProfileId));
Assert.That(imported.WorkspaceName, Is.EqualTo(template.WorkspaceName));
});
}
@@ -417,14 +420,66 @@ public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
public async Task MissingReferencesOfAChatTemplateAreNamed()
{
var missingDataSourceId = Guid.NewGuid().ToString();
var snippet = ExportChatTemplate([], toolIds: [TOOL_ID, "missing_tool"], dataSourceIds: [missingDataSourceId]);
var missingProviderId = Guid.NewGuid().ToString();
var missingProfileId = Guid.NewGuid().ToString();
var snippet = ExportChatTemplate([], toolIds: [TOOL_ID, "missing_tool"], dataSourceIds: [missingDataSourceId], providerId: missingProviderId, profileId: missingProfileId);
var result = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(snippet, PluginConfigurationObjectType.CHAT_TEMPLATE), this.SettingsManager, this.CreateRegistry(new TestTool(Definition())));
Assert.Multiple(() =>
{
Assert.That(result.Hints, Has.Count.EqualTo(2), "One sentence for each kind of reference.");
Assert.That(result.Hints, Has.Count.EqualTo(4), "One sentence for each kind of reference.");
Assert.That(result.Hints, Has.Exactly(1).Contains(missingDataSourceId));
Assert.That(result.Hints, Has.Exactly(1).Contains("missing_tool"));
Assert.That(result.Hints, Has.Exactly(1).Contains(missingProviderId));
Assert.That(result.Hints, Has.Exactly(1).Contains(missingProfileId));
});
}
[Test]
public async Task AChatTemplateWhichStartsWithoutAProfileNamesNothingMissing()
{
var snippet = ExportChatTemplate([], profileId: Profile.NO_PROFILE.Id);
var result = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(snippet, PluginConfigurationObjectType.CHAT_TEMPLATE), this.SettingsManager, this.CreateRegistry(new TestTool(Definition())));
Assert.Multiple(() =>
{
Assert.That(result.Hints, Is.Empty, "Starting without a profile is a statement, not a reference to a profile which could be missing.");
Assert.That(((ChatTemplate)result.ConfigurationObject).ProfileId, Is.EqualTo(Profile.NO_PROFILE.Id));
});
}
[Test]
public async Task AProviderBelowTheConfidenceOfTheChatIsNamed()
{
// A chat template names its provider by a GUID, as every provider of the app has one:
var provider = LessTrustedProvider() with { Id = Guid.NewGuid().ToString() };
this.SettingsManager.ConfigurationData.Providers.Add(provider);
this.SettingsManager.ConfigurationData.Confidence.EnforceGlobalMinimumConfidence = true;
this.SettingsManager.ConfigurationData.Confidence.GlobalMinimumConfidence = ConfidenceLevel.HIGH;
var snippet = ExportChatTemplate([], providerId: provider.Id);
var result = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(snippet, PluginConfigurationObjectType.CHAT_TEMPLATE), this.SettingsManager, this.CreateRegistry(new TestTool(Definition())));
Assert.Multiple(() =>
{
Assert.That(result.Hints, Has.Count.EqualTo(1), "The provider exists, but chats started with the template would not use it.");
Assert.That(result.Hints, Has.Exactly(1).Contains(provider.InstanceName));
});
}
[Test]
public async Task AProfileOfATemplateWhichForbidsProfilesIsNamed()
{
var missingProfileId = Guid.NewGuid().ToString();
var template = ChatTemplateWith([], null, null, profileId: missingProfileId) with { AllowProfileUsage = false };
Assert.That(template.TryExportAsConfigurationSection(out var snippet, out var issue), Is.True, issue);
var result = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(snippet, PluginConfigurationObjectType.CHAT_TEMPLATE), this.SettingsManager, this.CreateRegistry(new TestTool(Definition())));
Assert.Multiple(() =>
{
Assert.That(result.Hints, Has.Count.EqualTo(1), "Whether the profile exists does not matter, since chats ignore it anyway.");
Assert.That(result.Hints, Has.None.Contains(missingProfileId));
});
}
@@ -440,7 +495,7 @@ public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
var registry = this.CreateRegistry(new TestTool(Definition()));
var policy = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(ExportPolicy(provider.Id, profile.Id, [TOOL_ID]), PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY), this.SettingsManager, registry);
var template = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(ExportChatTemplate([], [TOOL_ID], [dataSource.Id]), PluginConfigurationObjectType.CHAT_TEMPLATE), this.SettingsManager, registry);
var template = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(ExportChatTemplate([], [TOOL_ID], [dataSource.Id], provider.Id, profile.Id), PluginConfigurationObjectType.CHAT_TEMPLATE), this.SettingsManager, registry);
Assert.Multiple(() =>
{
@@ -589,7 +644,7 @@ public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
return (profile, profile.ExportAsConfigurationSection());
case PluginConfigurationObjectType.CHAT_TEMPLATE:
var template = ChatTemplateWith([], ["web_search"], [Guid.NewGuid().ToString()]);
var template = ChatTemplateWith([], ["web_search"], [Guid.NewGuid().ToString()], Guid.NewGuid().ToString(), Guid.NewGuid().ToString()) with { WorkspaceName = "Contracts" };
Assert.That(template.TryExportAsConfigurationSection(out var luaCode, out var issue), Is.True, issue);
return (template, luaCode);
@@ -677,14 +732,14 @@ public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
return (dataSource, dataSource.ExportAsConfigurationSection(encryptedSecret, usernamePasswordMode));
}
private static string ExportChatTemplate(IReadOnlyList<string> attachmentPaths, IReadOnlyList<string>? toolIds = null, IReadOnlyList<string>? dataSourceIds = null)
private static string ExportChatTemplate(IReadOnlyList<string> attachmentPaths, IReadOnlyList<string>? toolIds = null, IReadOnlyList<string>? dataSourceIds = null, string? providerId = null, string? profileId = null)
{
var template = ChatTemplateWith(attachmentPaths, toolIds, dataSourceIds);
var template = ChatTemplateWith(attachmentPaths, toolIds, dataSourceIds, providerId, profileId);
Assert.That(template.TryExportAsConfigurationSection(out var luaCode, out var issue), Is.True, issue);
return luaCode;
}
private static ChatTemplate ChatTemplateWith(IReadOnlyList<string> attachmentPaths, IReadOnlyList<string>? toolIds, IReadOnlyList<string>? dataSourceIds) => new()
private static ChatTemplate ChatTemplateWith(IReadOnlyList<string> attachmentPaths, IReadOnlyList<string>? toolIds, IReadOnlyList<string>? dataSourceIds, string? providerId = null, string? profileId = null) => new()
{
Num = 1,
Id = Guid.NewGuid().ToString(),
@@ -700,6 +755,8 @@ public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
],
ToolIds = toolIds?.ToHashSet(StringComparer.Ordinal),
DataSourceOptions = dataSourceIds is null ? null : new() { DisableDataSources = false, AutomaticValidation = true, PreselectedDataSourceIds = [..dataSourceIds] },
ProviderId = providerId,
ProfileId = profileId,
};
private static string ExportPolicy(string preselectedProvider = "", string preselectedProfile = "", IReadOnlyList<string>? allowedToolIds = null) => PolicyWith(preselectedProvider, preselectedProfile, allowedToolIds ?? []).ExportAsConfigurationSection();
@@ -270,8 +270,12 @@ public sealed class ConfigurationSnippetParserTests
],
ToolIds = ["web_search"],
// Preselected data sources make the export start with a comment:
// Preselected data sources make the export start with a comment, and so do a preselected
// provider and profile, with a comment of their own:
DataSourceOptions = new() { DisableDataSources = false, PreselectedDataSourceIds = [Guid.NewGuid().ToString()] },
ProviderId = Guid.NewGuid().ToString(),
ProfileId = Guid.NewGuid().ToString(),
WorkspaceName = QUOTED_TEXT,
};
Assert.That(template.TryExportAsConfigurationSection(out var luaCode, out var issue), Is.True, issue);
@@ -0,0 +1,100 @@
using AIStudio.Settings;
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks how a chat finds the workspace its chat template names.
/// </summary>
/// <remarks>
/// A chat template names its workspace by name, because a workspace has an ID of its own on every
/// computer. The chat page and assistant chat launchers both go through the same method, so it has
/// to find the workspace the user sees in the list under that name, create it only when it is
/// missing, and never stop a chat over a name which names nothing.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class WorkspaceChatTemplateResolutionTests
{
private const string CHAT_TEMPLATE_NAME = "Mail";
private string? previousDataDirectory;
private string testDirectory = string.Empty;
[SetUp]
public async Task PrepareDataDirectory()
{
//
// The data directory and the workspace cache are static state of the whole application,
// which is why this fixture does not run alongside others, see WorkspaceTreeCacheTests:
//
this.previousDataDirectory = SettingsManager.DataDirectory;
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-workspace-template-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.testDirectory);
SettingsManager.DataDirectory = this.testDirectory;
WorkspaceBehaviour.TestDataDirectory = this.testDirectory;
await WorkspaceBehaviour.ForceReloadWorkspaceTreeAsync();
}
[TearDown]
public void RemoveDataDirectory()
{
SettingsManager.DataDirectory = this.previousDataDirectory;
WorkspaceBehaviour.TestDataDirectory = null;
// Whoever reads the cache next must not get the workspaces of this test:
WorkspaceBehaviour.InvalidateWorkspaceTreeCache();
try
{
Directory.Delete(this.testDirectory, true);
}
catch (IOException)
{
// A temporary directory we could not remove says nothing about the code under test.
}
}
[Test]
public async Task AnExistingWorkspaceIsFoundByItsNameWithoutSpacesInAnyCase()
{
var created = await WorkspaceBehaviour.TryCreateWorkspaceAsync("Mails");
Assert.That(created.Success, Is.True);
var workspaceId = await WorkspaceBehaviour.ResolveOrCreateChatTemplateWorkspaceAsync(" mails ", CHAT_TEMPLATE_NAME);
var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
Assert.Multiple(() =>
{
Assert.That(workspaceId, Is.EqualTo(created.Workspace.WorkspaceId), "The template names the workspace the user sees in the list under that name.");
Assert.That(snapshot.Workspaces, Has.Count.EqualTo(1), "A second workspace of the same name would split the chats of the template.");
});
}
[Test]
public async Task AMissingWorkspaceIsCreated()
{
var workspaceId = await WorkspaceBehaviour.ResolveOrCreateChatTemplateWorkspaceAsync("Contracts", CHAT_TEMPLATE_NAME);
var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
Assert.Multiple(() =>
{
Assert.That(workspaceId, Is.Not.EqualTo(Guid.Empty));
Assert.That(snapshot.Workspaces.Single(workspace => workspace.WorkspaceId == workspaceId).Name, Is.EqualTo("Contracts"));
});
}
[Test]
public async Task ANameWhichNamesNothingGivesNoWorkspace()
{
var workspaceId = await WorkspaceBehaviour.ResolveOrCreateChatTemplateWorkspaceAsync(" ", CHAT_TEMPLATE_NAME);
var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
Assert.Multiple(() =>
{
Assert.That(workspaceId, Is.EqualTo(Guid.Empty), "The chat then starts without a workspace, as it would without the template.");
Assert.That(snapshot.Workspaces, Is.Empty, "A workspace without a name would only show up as an unnamed one in the list.");
});
}
}