From b3fe7e518c67b1b54856e2df64e1254ebd9ccee2 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 21 Sep 2026 10:22:51 +0200 Subject: [PATCH] Export the preselected tools and data sources of a chat template --- .../Settings/ChatTemplate.cs | 91 ++++++- .../ChatTemplateConfigurationTests.cs | 239 ++++++++++++++++++ 2 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 app/Tests/Settings/ChatTemplateConfigurationTests.cs diff --git a/app/MindWork AI Studio/Settings/ChatTemplate.cs b/app/MindWork AI Studio/Settings/ChatTemplate.cs index 85b6d698..12532743 100644 --- a/app/MindWork AI Studio/Settings/ChatTemplate.cs +++ b/app/MindWork AI Studio/Settings/ChatTemplate.cs @@ -369,15 +369,24 @@ public record ChatTemplate( { issue = string.Empty; var fileAttachmentsLua = this.BuildFileAttachmentsLua(fileAttachmentPaths); + + // + // Both of these may be absent entirely, because saying nothing about tools or data sources + // is a statement of its own. They therefore bring their own line break and indentation + // instead of sitting on a line of the template: + // + var toolIdsLua = this.BuildToolIdsLua(); + var dataSourceOptionsLua = this.BuildDataSourceOptionsLua(); + luaCode = $$""" - CONFIG["CHAT_TEMPLATES"][#CONFIG["CHAT_TEMPLATES"]+1] = { + {{this.BuildDataSourceIdNote()}}CONFIG["CHAT_TEMPLATES"][#CONFIG["CHAT_TEMPLATES"]+1] = { ["Id"] = "{{LuaTools.EscapeLuaString(exportId)}}", ["Name"] = {{LuaTools.ToLuaStringLiteral(this.Name)}}, ["SystemPrompt"] = {{LuaTools.ToLuaStringLiteral(this.SystemPrompt)}}, ["PredefinedUserPrompt"] = {{LuaTools.ToLuaStringLiteral(this.PredefinedUserPrompt)}}, ["AllowProfileUsage"] = {{this.AllowProfileUsage.ToString().ToLowerInvariant()}}, ["FileAttachments"] = {{fileAttachmentsLua}}, - ["ExampleConversation"] = {{exampleConversationLua}}, + ["ExampleConversation"] = {{exampleConversationLua}},{{toolIdsLua}}{{dataSourceOptionsLua}} } """; return true; @@ -487,6 +496,84 @@ public record ChatTemplate( return true; } + /// + /// An empty set is written out as an empty table rather than being left out: the two say + /// different things, and dropping the line would turn "no tools at all" into "whatever the + /// chat default is" on the machine which reads this back. + /// + private string BuildToolIdsLua() + { + if (this.ToolIds is null) + return string.Empty; + + var builder = new StringBuilder(); + builder.AppendLine(); + if (this.ToolIds.Count == 0) + { + builder.Append(""" ["ToolIds"] = {},"""); + return builder.ToString(); + } + + builder.AppendLine(""" ["ToolIds"] = {"""); + + // + // A set has no order of its own, so exporting the same template twice would otherwise + // produce two different files. Sorting keeps the plugin diffs readable: + // + foreach (var toolId in this.ToolIds.Order(StringComparer.Ordinal)) + builder.AppendLine($" {LuaTools.ToLuaStringLiteral(toolId)},"); + + builder.Append(" },"); + return builder.ToString(); + } + + private string BuildDataSourceOptionsLua() + { + if (this.DataSourceOptions is not { } options) + return string.Empty; + + var builder = new StringBuilder(); + builder.AppendLine(); + builder.AppendLine(""" ["DataSourceOptions"] = {"""); + builder.AppendLine($""" ["DisableDataSources"] = {options.DisableDataSources.ToString().ToLowerInvariant()},"""); + builder.AppendLine($""" ["AutomaticDataSourceSelection"] = {options.AutomaticDataSourceSelection.ToString().ToLowerInvariant()},"""); + builder.AppendLine($""" ["AutomaticValidation"] = {options.AutomaticValidation.ToString().ToLowerInvariant()},"""); + + if (options.PreselectedDataSourceIds.Count == 0) + builder.AppendLine(""" ["PreselectedDataSourceIds"] = {},"""); + else + { + builder.AppendLine(""" ["PreselectedDataSourceIds"] = {"""); + foreach (var dataSourceId in options.PreselectedDataSourceIds) + builder.AppendLine($" {LuaTools.ToLuaStringLiteral(dataSourceId)},"); + + builder.AppendLine(" },"); + } + + builder.Append(" },"); + return builder.ToString(); + } + + /// + /// The template itself gets a fresh ID on export, but the data source IDs must not: they point + /// at the sources of the organization and only work when both sides agree on them. Nobody can + /// see that from the exported code alone, hence this note. + /// + private string BuildDataSourceIdNote() + { + if (this.DataSourceOptions is not { PreselectedDataSourceIds.Count: > 0 }) + return string.Empty; + + // The empty line before the closing delimiter is what ends the last comment line. Without + // it, the assignment would continue that comment and the whole export would be one comment: + return """ + -- The data source IDs below are the ones of the machine this was exported from. + -- Please check them against your CONFIG["DATA_SOURCES"]: an ID which resolves to + -- nothing is ignored, and a chat with this template then starts without that source. + + """; + } + private string BuildFileAttachmentsLua(IReadOnlyList? fileAttachmentPaths) { var paths = fileAttachmentPaths ?? this.FileAttachments.Select(attachment => attachment.FilePath).ToList(); diff --git a/app/Tests/Settings/ChatTemplateConfigurationTests.cs b/app/Tests/Settings/ChatTemplateConfigurationTests.cs new file mode 100644 index 00000000..5d5959dc --- /dev/null +++ b/app/Tests/Settings/ChatTemplateConfigurationTests.cs @@ -0,0 +1,239 @@ +using AIStudio.Settings; +using AIStudio.Settings.DataModel; + +using Lua; +using Lua.Standard; + +namespace AIStudio.Tests.Settings; + +/// +/// Checks the tools and data sources a chat template carries, across the two surfaces which have +/// to agree on them: the Lua a configuration plugin states, and the Lua the app exports. +/// +/// +/// The interesting part is not that a value survives, but that the difference between "this +/// template says nothing" and "this template says none" survives. Both end up as an empty +/// selection in the chat on a fresh installation, so a mistake here stays invisible until somebody +/// sets a default tool for their chats -- and then quietly hands out a tool the template ruled out. +/// +[TestFixture] +public sealed class ChatTemplateConfigurationTests +{ + private static readonly Guid PLUGIN_ID = new("22222222-2222-2222-2222-222222222222"); + + [Test] + public async Task WhatTheAppExportsIsWhatAConfigurationPluginCanReadBack() + { + var written = NewTemplate() with + { + ToolIds = ["read_web_page", "web_search"], + DataSourceOptions = new() + { + DisableDataSources = false, + AutomaticDataSourceSelection = false, + AutomaticValidation = true, + PreselectedDataSourceIds = ["11111111-1111-1111-1111-111111111111"], + }, + }; + + var read = await ExportAndReadBackAsync(written); + + Assert.Multiple(() => + { + Assert.That(read.ToolIds, Is.EquivalentTo(written.ToolIds!)); + Assert.That(read.DataSourceOptions, Is.Not.Null); + Assert.That(read.DataSourceOptions!.DisableDataSources, Is.False); + Assert.That(read.DataSourceOptions.AutomaticDataSourceSelection, Is.False); + Assert.That(read.DataSourceOptions.AutomaticValidation, Is.True); + Assert.That(read.DataSourceOptions.PreselectedDataSourceIds, Is.EqualTo(written.DataSourceOptions!.PreselectedDataSourceIds)); + }); + } + + [Test] + public async Task AnAgenticSelectionSurvivesTheExportAsWell() + { + var written = NewTemplate() with + { + DataSourceOptions = new() + { + DisableDataSources = false, + AutomaticDataSourceSelection = true, + AutomaticValidation = true, + PreselectedDataSourceIds = [], + }, + }; + + var read = await ExportAndReadBackAsync(written); + + Assert.Multiple(() => + { + Assert.That(read.DataSourceOptions, Is.Not.Null); + Assert.That(read.DataSourceOptions!.AutomaticDataSourceSelection, Is.True, "Letting an agent pick the sources is the one thing only a chat template can state, so it must not be lost on the way through Lua."); + Assert.That(read.DataSourceOptions.PreselectedDataSourceIds, Is.Empty); + Assert.That(read.ToolIds, Is.Null); + }); + } + + [Test] + public async Task ATemplateWhichSaysNothingExportsNeitherTable() + { + var written = NewTemplate(); + Assert.That(written.TryExportAsConfigurationSection(out var luaCode, out var issue), Is.True, issue); + + Assert.Multiple(() => + { + Assert.That(luaCode, Does.Not.Contain("ToolIds")); + Assert.That(luaCode, Does.Not.Contain("DataSourceOptions")); + }); + + var read = await ParseAsync(luaCode); + + Assert.Multiple(() => + { + Assert.That(read.ToolIds, Is.Null, "A template without a tool selection must stay without one, so the chat keeps using its own default."); + Assert.That(read.DataSourceOptions, Is.Null); + }); + } + + [Test] + public async Task ATemplateWhichRulesOutEveryToolStaysThatWay() + { + var written = NewTemplate() with { ToolIds = [] }; + var read = await ExportAndReadBackAsync(written); + + Assert.That(read.ToolIds, Is.Not.Null, "An empty selection is the statement that this template wants no tools. Reading it back as null would hand out the chat default instead."); + Assert.That(read.ToolIds, Is.Empty); + } + + [Test] + public async Task NamingTheDataSourceOptionsAtAllSwitchesDataSourcesOn() + { + var read = await ParseAsync(""" + CONFIG["CHAT_TEMPLATES"][#CONFIG["CHAT_TEMPLATES"]+1] = { + ["Id"] = "33333333-3333-3333-3333-333333333333", + ["Name"] = "Intranet Research", + ["SystemPrompt"] = "You are a research assistant.", + ["DataSourceOptions"] = { + ["PreselectedDataSourceIds"] = { + "11111111-1111-1111-1111-111111111111", + }, + }, + } + """); + + Assert.That(read.DataSourceOptions, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(read.DataSourceOptions!.DisableDataSources, Is.False, "Writing this table is already the statement that the template wants data sources, so an omitted switch must not turn them off again."); + Assert.That(read.DataSourceOptions.AutomaticDataSourceSelection, Is.False); + Assert.That(read.DataSourceOptions.AutomaticValidation, Is.False); + Assert.That(read.DataSourceOptions.PreselectedDataSourceIds, Is.EqualTo(new[] { "11111111-1111-1111-1111-111111111111" })); + }); + } + + [Test] + public async Task AnUnusableEntryIsSkippedAndTheRestOfTheListSurvives() + { + var read = await ParseAsync(""" + CONFIG["CHAT_TEMPLATES"][#CONFIG["CHAT_TEMPLATES"]+1] = { + ["Id"] = "33333333-3333-3333-3333-333333333333", + ["Name"] = "Intranet Research", + ["SystemPrompt"] = "You are a research assistant.", + ["ToolIds"] = { + "web_search", + "", + {}, + "read_web_page", + }, + ["DataSourceOptions"] = { + ["PreselectedDataSourceIds"] = { + "11111111-1111-1111-1111-111111111111", + " ", + }, + }, + } + """); + + Assert.Multiple(() => + { + Assert.That(read.ToolIds, Is.EquivalentTo(new[] { "web_search", "read_web_page" })); + Assert.That(read.DataSourceOptions!.PreselectedDataSourceIds, Is.EqualTo(new[] { "11111111-1111-1111-1111-111111111111" })); + }); + } + + [Test] + public void ExportedDataSourceIdsComeWithTheNoteThatTheyAreLocalOnes() + { + var withSources = NewTemplate() with + { + DataSourceOptions = new() { DisableDataSources = false, PreselectedDataSourceIds = ["11111111-1111-1111-1111-111111111111"] }, + }; + + var agenticOnly = NewTemplate() with + { + DataSourceOptions = new() { DisableDataSources = false, AutomaticDataSourceSelection = true }, + }; + + Assert.That(withSources.TryExportAsConfigurationSection(out var withSourcesLua, out var issue), Is.True, issue); + Assert.That(agenticOnly.TryExportAsConfigurationSection(out var agenticOnlyLua, out issue), Is.True, issue); + + Assert.Multiple(() => + { + Assert.That(withSourcesLua, Does.StartWith("--"), "Whoever pastes this into a plugin cannot see from the IDs alone that they belong to another machine."); + Assert.That(agenticOnlyLua, Does.Not.StartWith("--"), "Without IDs there is nothing to check, so the note would only be noise."); + }); + } + + /// + /// A template with the parts every export needs, and nothing said about tools or data sources. + /// + private static ChatTemplate NewTemplate() => new() + { + Num = 1, + Id = "33333333-3333-3333-3333-333333333333", + Name = "Intranet Research", + SystemPrompt = "You are a research assistant.", + PredefinedUserPrompt = string.Empty, + ExampleConversation = [], + FileAttachments = [], + AllowProfileUsage = true, + }; + + private static async Task ExportAndReadBackAsync(ChatTemplate template) + { + Assert.That(template.TryExportAsConfigurationSection(out var luaCode, out var issue), Is.True, issue); + return await ParseAsync(luaCode); + } + + /// + /// Reads a chat template the way a configuration plugin states it. + /// + /// + /// Through a real Lua state rather than a table put together in C#, so that the exported code + /// has to be valid Lua before anything else is checked. + /// + /// The lines a plugin would contain, including the assignment itself. + /// The chat template read from it. + private static async Task ParseAsync(string luaCode) + { + var state = LuaState.Create(); + state.OpenBasicLibrary(); + state.OpenTableLibrary(); + + await state.DoStringAsync($$""" + CONFIG = {} + CONFIG["CHAT_TEMPLATES"] = {} + {{luaCode}} + """); + + if (!state.Environment["CONFIG"].TryRead(out var configTable) || + !configTable["CHAT_TEMPLATES"].TryRead(out var templatesTable) || + !templatesTable[1].TryRead(out var templateTable)) + throw new InvalidOperationException("The code of this test did not produce a chat template table."); + + if (!ChatTemplate.TryParseChatTemplateTable(1, templateTable, PLUGIN_ID, string.Empty, out var parsed) || parsed is not ChatTemplate chatTemplate) + throw new InvalidOperationException("The chat template of this test could not be read."); + + return chatTemplate; + } +} \ No newline at end of file