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