diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs index 4c07fa87c..4a0a2c8be 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs @@ -281,7 +281,7 @@ public abstract partial class AssistantBase : AssistantLowerBase wher this.formChangeTimer.Elapsed += (_, _) => { this.formChangeTimer.Stop(); - this.InvokeAsync(this.OnFormChange).Observe($"{nameof(AssistantBase)}: handling a form change"); + this.InvokeAsync(this.OnFormChange).Observe($"{nameof(AssistantBase<>)}: handling a form change"); }; this.MightPreselectValues(); @@ -321,7 +321,7 @@ public abstract partial class AssistantBase : AssistantLowerBase wher #endregion - private string TB(string fallbackEN) => this.T(fallbackEN, typeof(AssistantBase).Namespace, nameof(AssistantBase)); + private string TB(string fallbackEN) => this.T(fallbackEN, typeof(AssistantBase<>).Namespace, nameof(AssistantBase<>)); private string SubmitButtonStyle => this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? this.ProviderSettings.UsedLLMProvider.GetConfidence(this.SettingsManager).StyleBorder(this.SettingsManager) : string.Empty; @@ -470,7 +470,7 @@ public abstract partial class AssistantBase : AssistantLowerBase wher Array.Resize(ref this.InputIssues, this.InputIssues.Length + 1); this.InputIssues[^1] = issue; this.InputIsValid = false; - this.RefreshAssistantUIAsync().Observe($"{nameof(AssistantBase)}: rendering an added input issue"); + this.RefreshAssistantUIAsync().Observe($"{nameof(AssistantBase<>)}: rendering an added input issue"); } /// @@ -480,7 +480,7 @@ public abstract partial class AssistantBase : AssistantLowerBase wher { this.InputIssues = []; this.InputIsValid = true; - this.RefreshAssistantUIAsync().Observe($"{nameof(AssistantBase)}: rendering cleared input issues"); + this.RefreshAssistantUIAsync().Observe($"{nameof(AssistantBase<>)}: rendering cleared input issues"); } protected void CreateChatThread() @@ -1009,7 +1009,7 @@ public abstract partial class AssistantBase : AssistantLowerBase wher { await this.ConsumeMediaOutcomeAsync(); this.StateHasChanged(); - }).Observe($"{nameof(AssistantBase)}: consuming a media import outcome"); + }).Observe($"{nameof(AssistantBase<>)}: consuming a media import outcome"); } /// Consumes a terminal media notification when this assistant is visible. diff --git a/app/MindWork AI Studio/Assistants/Builder/LauncherTextsResponse.cs b/app/MindWork AI Studio/Assistants/Builder/LauncherTextsResponse.cs index 4b2ad4491..ad8c7c917 100644 --- a/app/MindWork AI Studio/Assistants/Builder/LauncherTextsResponse.cs +++ b/app/MindWork AI Studio/Assistants/Builder/LauncherTextsResponse.cs @@ -14,12 +14,20 @@ internal sealed class LauncherTextsResponse { public const string SCHEMA_VERSION_VALUE = "assistant_builder_launcher_texts_v1"; + /// + /// How the answer of the model is read. + /// + /// + /// An answer which names a field twice leaves open which of the values the model meant, so it is + /// refused like any other invalid JSON. + /// internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, AllowTrailingCommas = false, ReadCommentHandling = JsonCommentHandling.Disallow, MaxDepth = 8, + AllowDuplicateProperties = false, TypeInfoResolver = LauncherTextsResponseJsonContext.Default, }.AsReadOnly(); diff --git a/app/MindWork AI Studio/Assistants/Builder/LuaResponse.Parse.cs b/app/MindWork AI Studio/Assistants/Builder/LuaResponse.Parse.cs index 37e2ceb99..dda7999dd 100644 --- a/app/MindWork AI Studio/Assistants/Builder/LuaResponse.Parse.cs +++ b/app/MindWork AI Studio/Assistants/Builder/LuaResponse.Parse.cs @@ -4,12 +4,20 @@ namespace AIStudio.Assistants.Builder; internal sealed partial class LuaResponse { + /// + /// How the answer of the model is read. + /// + /// + /// An answer which names a field twice leaves open which of the values the model meant, the Lua code + /// included, so it is refused like any other invalid JSON. + /// internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, AllowTrailingCommas = false, ReadCommentHandling = JsonCommentHandling.Disallow, MaxDepth = 32, + AllowDuplicateProperties = false, TypeInfoResolver = LuaResponseJsonContext.Default, }.AsReadOnly(); diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingJson.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingJson.cs index c1a8885b2..403c72151 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingJson.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingJson.cs @@ -31,6 +31,9 @@ internal static class VisualBriefingJson /// here even though the persisted manifest writes their member names. Unlike other options of the app, /// these are not made read-only right here, because MWAIS0010 wants nothing but the object creation; /// System.Text.Json locks them with their first use anyway. + /// + /// The answers of the model are read with these options, too, which therefore refuse a field named + /// twice. That rule only concerns reading and changes no byte of the output. /// [CanonicalJsonConfiguration] internal static JsonSerializerOptions Canonical { get; } = new() @@ -40,6 +43,7 @@ internal static class VisualBriefingJson WriteIndented = false, Encoder = JavaScriptEncoder.Default, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + AllowDuplicateProperties = false, TypeInfoResolver = VisualBriefingCanonicalJsonContext.Default, }; diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStructuredResponseIssueKind.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStructuredResponseIssueKind.cs index c33c47b0b..0dc56b0a2 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStructuredResponseIssueKind.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStructuredResponseIssueKind.cs @@ -40,4 +40,7 @@ public enum VisualBriefingStructuredResponseIssueKind /// The parsed response violated a semantic stage contract. SEMANTIC_CONTRACT_INVALID, + + /// An object of the response named the same field more than once. + DUPLICATE_FIELD, } \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStructuredResponseProcessor.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStructuredResponseProcessor.cs index e27d4bca4..d082a0577 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStructuredResponseProcessor.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStructuredResponseProcessor.cs @@ -20,7 +20,7 @@ internal static partial class VisualBriefingStructuredResponseProcessor .DisableHtml() .Build(); private static readonly NullabilityInfoContext NULLABILITY = new(); - private static readonly object CONTRACT_LOCK = new(); + private static readonly Lock CONTRACT_LOCK = new(); private static readonly Dictionary CONTRACTS = []; /// @@ -159,6 +159,18 @@ internal static partial class VisualBriefingStructuredResponseProcessor "The structured response root must be a JSON object.", expected: "JSON object"); + var duplicateIssue = FindDuplicateField(document.RootElement, "$"); + if (duplicateIssue is not null) + return Rejected( + candidate, + duplicateIssue.Kind, + VisualBriefingFailureCode.RESPONSE_JSON_INVALID, + VisualBriefingValidationRule.JSON_INVALID, + ContractIssueMessage(duplicateIssue), + duplicateIssue.Path, + fieldName: duplicateIssue.FieldName, + expected: duplicateIssue.Expected); + var contractIssue = Inspect( document.RootElement, GetContract(typeof(T)), @@ -377,6 +389,58 @@ internal static partial class VisualBriefingStructuredResponseProcessor private static long? ToResponseLine(ResponseCandidate candidate, long? candidateLine) => candidateLine is null ? null : candidate.StartLine + candidateLine; + /// + /// Finds the first field which an object of the response names a second time. + /// + /// + /// JSON allows a name twice, and readers disagree on which of the values counts: the checks below + /// see one of them, while the browser which runs the finished briefing may see the other. The + /// response is refused instead, and the repair names the field. This looks into every value, also + /// into those the contract leaves open, such as the data of a chart. + /// + private static ContractInspectionIssue? FindDuplicateField(JsonElement element, string path) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in element.EnumerateObject()) + { + var safeField = SafeIdentifier(property.Name); + var fieldPath = string.IsNullOrEmpty(safeField) ? path : $"{path}.{safeField}"; + if (!names.Add(property.Name)) + return new( + VisualBriefingStructuredResponseIssueKind.DUPLICATE_FIELD, + fieldPath, + safeField, + "every field name only once per object"); + + var issue = FindDuplicateField(property.Value, fieldPath); + if (issue is not null) + return issue; + } + + return null; + } + case JsonValueKind.Array: + { + var index = 0; + foreach (var item in element.EnumerateArray()) + { + var issue = FindDuplicateField(item, $"{path}[{index}]"); + if (issue is not null) + return issue; + index++; + } + + return null; + } + default: + return null; + } + } + private static ContractInspectionIssue? Inspect( JsonElement element, ContractShape shape, @@ -496,6 +560,10 @@ internal static partial class VisualBriefingStructuredResponseProcessor $"The model response contains the unknown field '{issue.FieldName}' at {issue.Path}.", VisualBriefingStructuredResponseIssueKind.UNKNOWN_FIELD => $"The model response contains an unknown field at {issue.Path}.", + VisualBriefingStructuredResponseIssueKind.DUPLICATE_FIELD when !string.IsNullOrEmpty(issue.FieldName) => + $"The model response contains the field '{issue.FieldName}' more than once at {issue.Path}.", + VisualBriefingStructuredResponseIssueKind.DUPLICATE_FIELD => + $"The model response contains a field more than once at {issue.Path}.", VisualBriefingStructuredResponseIssueKind.REQUIRED_FIELD_MISSING => $"The required field '{issue.FieldName}' is missing at {issue.Path}.", VisualBriefingStructuredResponseIssueKind.ENUM_VALUE_INVALID => diff --git a/app/MindWork AI Studio/Components/AssistantBlock.razor.cs b/app/MindWork AI Studio/Components/AssistantBlock.razor.cs index dc170b914..2fbf5d7ca 100644 --- a/app/MindWork AI Studio/Components/AssistantBlock.razor.cs +++ b/app/MindWork AI Studio/Components/AssistantBlock.razor.cs @@ -167,7 +167,7 @@ public partial class AssistantBlock : MSGComponentBase, IAssistantCat private void OnMediaImportStateChanged(MediaImportOwner owner) { if (this.OwnedByThisBlock(owner)) - this.InvokeAsync(this.StateHasChanged).Observe($"{nameof(AssistantBlock)}: rendering a media import change"); + this.InvokeAsync(this.StateHasChanged).Observe($"{nameof(AssistantBlock<>)}: rendering a media import change"); } protected override void DisposeResources() diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index c08c655f9..21db2c4cb 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -1011,9 +1011,7 @@ public partial class ChatComponent : MSGComponentBase var automaticOptions = this.GetAutomaticDataSourceOptions(); this.lastAppliedAutomaticDataSourceOptions = automaticOptions.CreateCopy(); this.earlyDataSourceOptions = automaticOptions; - if(this.ChatThread is not null) - this.ChatThread.DataSourceOptions = automaticOptions; - + this.ChatThread?.DataSourceOptions = automaticOptions; this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(automaticOptions); } diff --git a/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor.cs b/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor.cs index 143f00bbb..1cb0eae3c 100644 --- a/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor.cs @@ -575,7 +575,7 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId this.dataLoadingModelsIssue = result.FailureReason.ToUserMessage(provider.InstanceName); // Order descending by ID means that the newest models probably come first: - var orderedModels = result.Models.OrderByDescending(n => n.Id); + var orderedModels = result.Models.OrderByDescending(n => n.Id, Model.ID_ORDER); this.availableModels.Clear(); this.availableModels.AddRange(orderedModels); diff --git a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs index 866226fc6..8d25044ca 100644 --- a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor.cs @@ -623,7 +623,7 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId this.dataLoadingModelsIssue = result.FailureReason.ToUserMessage(provider.InstanceName); // Order descending by ID means that the newest models probably come first: - var orderedModels = result.Models.OrderByDescending(n => n.Id); + var orderedModels = result.Models.OrderByDescending(n => n.Id, Model.ID_ORDER); this.availableModels.Clear(); this.availableModels.AddRange(orderedModels); diff --git a/app/MindWork AI Studio/Dialogs/TranscriptionProviderDialog.razor.cs b/app/MindWork AI Studio/Dialogs/TranscriptionProviderDialog.razor.cs index e83f33204..7fcecf6e7 100644 --- a/app/MindWork AI Studio/Dialogs/TranscriptionProviderDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/TranscriptionProviderDialog.razor.cs @@ -357,7 +357,7 @@ public partial class TranscriptionProviderDialog : MSGComponentBase, ISecretId this.dataLoadingModelsIssue = result.FailureReason.ToUserMessage(provider.InstanceName); // Order descending by ID means that the newest models probably come first: - var orderedModels = result.Models.OrderByDescending(n => n.Id); + var orderedModels = result.Models.OrderByDescending(n => n.Id, Model.ID_ORDER); this.availableModels.Clear(); this.availableModels.AddRange(orderedModels); diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index c621bf806..7771b8e01 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -69,7 +69,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage; - private FileExportNotifications FileExportNotifications => this.fileExportNotifications ??= new(this.Snackbar); + private FileExportNotifications FileExportNotifications => field ??= new(this.Snackbar); private string PaddingLeft => this.navBarOpen ? $"padding-left: {NAVBAR_EXPANDED_WIDTH_INT - NAVBAR_COLLAPSED_WIDTH_INT}em;" : "padding-left: 0em;"; @@ -85,7 +85,6 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan private bool useDarkMode; private bool startupCompleted; private bool settingsWriteProtectionWarningShown; - private FileExportNotifications? fileExportNotifications; private readonly SemaphoreSlim mandatoryInfoDialogSemaphore = new(1, 1); private readonly SemaphoreSlim promptInjectionDialogSemaphore = new(1, 1); private readonly SemaphoreSlim dataRestorePendingDialogSemaphore = new(1, 1); diff --git a/app/MindWork AI Studio/Models/ModelFamily.cs b/app/MindWork AI Studio/Models/ModelFamily.cs index fb8ce0072..378e281c9 100644 --- a/app/MindWork AI Studio/Models/ModelFamily.cs +++ b/app/MindWork AI Studio/Models/ModelFamily.cs @@ -17,8 +17,6 @@ namespace AIStudio.Models; /// public abstract class ModelFamily { - private IReadOnlyList? declaredRules; - /// /// Who builds the models of this family. /// @@ -48,7 +46,7 @@ public abstract class ModelFamily /// /// The rules this family states, worked out once. /// - public IReadOnlyList Rules => this.declaredRules ??= this.BuildRules(); + public IReadOnlyList Rules => field ??= this.BuildRules(); /// /// Adjusts a profile in a way no pattern can express. diff --git a/app/MindWork AI Studio/Provider/AdditionalApiParametersParser.cs b/app/MindWork AI Studio/Provider/AdditionalApiParametersParser.cs index 5cb2481b8..ab2c905b5 100644 --- a/app/MindWork AI Studio/Provider/AdditionalApiParametersParser.cs +++ b/app/MindWork AI Studio/Provider/AdditionalApiParametersParser.cs @@ -13,6 +13,19 @@ namespace AIStudio.Provider; /// public static class AdditionalApiParametersParser { + /// + /// Every name only once per object. + /// + /// + /// The dialog for providers refuses a name written twice, but a configuration plugin brings its + /// providers past the dialog. With both values read, it would be open which of them reaches the + /// provider, and the dictionary below would refuse the second one with an exception. + /// + private static readonly JsonDocumentOptions DOCUMENT_OPTIONS = new() + { + AllowDuplicateProperties = false, + }; + /// /// Try to parse an additional-API-parameters JSON fragment into a dictionary. /// @@ -30,7 +43,7 @@ public static class AdditionalApiParametersParser try { // The UI stores only the object body, so wrap it before parsing. - using var jsonDoc = JsonDocument.Parse($"{{{additionalJsonApiParameters}}}"); + using var jsonDoc = JsonDocument.Parse($"{{{additionalJsonApiParameters}}}", DOCUMENT_OPTIONS); parameters = ConvertToDictionary(jsonDoc.RootElement); return true; } diff --git a/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs b/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs index 635dfafef..1d5929439 100644 --- a/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs +++ b/app/MindWork AI Studio/Provider/AlibabaCloud/ProviderAlibabaCloud.cs @@ -77,7 +77,7 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C public override async Task GetTextModels(string? apiKeyProvisional = null, CancellationToken token = default) { var result = await this.LoadModels(SecretStoreType.LLM_PROVIDER, apiKeyProvisional, token); - return result with { Models = [..result.Models.Where(model => model.IsChatModel(this.Provider)).OrderBy(x => x.Id)] }; + return result with { Models = [..result.Models.Where(model => model.IsChatModel(this.Provider)).OrderBy(x => x.Id, Model.ID_ORDER)] }; } /// @@ -90,7 +90,7 @@ public sealed class ProviderAlibabaCloud() : BaseProvider(LLMProviders.ALIBABA_C public override async Task GetEmbeddingModels(string? apiKeyProvisional = null, CancellationToken token = default) { var result = await this.LoadModels(SecretStoreType.EMBEDDING_PROVIDER, apiKeyProvisional, token); - return result with { Models = [..result.Models.Where(model => model.IsEmbeddingModel(this.Provider)).OrderBy(x => x.Id)] }; + return result with { Models = [..result.Models.Where(model => model.IsEmbeddingModel(this.Provider)).OrderBy(x => x.Id, Model.ID_ORDER)] }; } #region Overrides of BaseProvider diff --git a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs index 52dacb200..a35f36cf5 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs @@ -218,7 +218,7 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n // has not always been only something to talk to. The six above skip that question // because they are not a catalog: every one of them was picked by hand. // - Models = [..result.Models.Where(model => model.IsChatModel(this.Provider)).Concat(additionalModels).DistinctBy(x => x.Id).OrderBy(x => x.Id)] + Models = [..result.Models.Where(model => model.IsChatModel(this.Provider)).Concat(additionalModels).DistinctBy(x => x.Id).OrderBy(x => x.Id, Model.ID_ORDER)] }; } diff --git a/app/MindWork AI Studio/Provider/Model.cs b/app/MindWork AI Studio/Provider/Model.cs index 97ca3bbfc..978e7a859 100644 --- a/app/MindWork AI Studio/Provider/Model.cs +++ b/app/MindWork AI Studio/Provider/Model.cs @@ -1,3 +1,5 @@ +using System.Globalization; + using AIStudio.Tools.PluginSystem; namespace AIStudio.Provider; @@ -20,6 +22,16 @@ public readonly record struct Model(string Id, string? DisplayName) /// public static readonly Model SYSTEM_MODEL = new(SYSTEM_MODEL_ID, null); + /// + /// Orders model IDs by the numbers in them, as people read them. + /// + /// + /// Compared digit by digit, "gpt-4.10" comes before "gpt-4.9", "o10" before "o3", and a list with the + /// newest models first puts an older one on top. The order does not depend on the language of the + /// operating system either. + /// + public static readonly StringComparer ID_ORDER = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); + /// /// Checks if this model is the system-configured placeholder. /// diff --git a/app/MindWork AI Studio/Tools/ChatArchive/ChatArchiveImporter.cs b/app/MindWork AI Studio/Tools/ChatArchive/ChatArchiveImporter.cs index d010073f9..fa23bd385 100644 --- a/app/MindWork AI Studio/Tools/ChatArchive/ChatArchiveImporter.cs +++ b/app/MindWork AI Studio/Tools/ChatArchive/ChatArchiveImporter.cs @@ -70,7 +70,7 @@ public static class ChatArchiveImporter { try { - using var archive = ZipFile.OpenRead(archivePath); + await using var archive = await ZipFile.OpenReadAsync(archivePath, token); var manifestResult = await DataBackupReader.ReadManifestAsync(archive, token); if (manifestResult.Manifest is not { } manifest) return new(false, new(), manifestResult.Issue); @@ -114,7 +114,7 @@ public static class ChatArchiveImporter try { var manifest = preview.Manifest; - using var archive = ZipFile.OpenRead(archivePath); + await using var archive = await ZipFile.OpenReadAsync(archivePath, token); var entriesByChat = GroupEntriesByChat(archive); var provenance = await ExtractProvenanceStoreAsync(archive, provenanceStore, provenanceDirectory, token); @@ -243,7 +243,7 @@ public static class ChatArchiveImporter /// The cancellation token. private static async Task ExtractEntryAsync(ZipArchiveEntry entry, string destinationPath, CancellationToken token) { - await using var entryStream = entry.Open(); + await using var entryStream = await entry.OpenAsync(token); await using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous | FileOptions.SequentialScan); await entryStream.CopyToAsync(fileStream, token); } @@ -446,7 +446,7 @@ public static class ChatArchiveImporter private static async Task ReadTextAsync(ZipArchiveEntry entry, CancellationToken token) { - using var reader = new StreamReader(entry.Open()); + using var reader = new StreamReader(await entry.OpenAsync(token)); return (await reader.ReadToEndAsync(token)).Trim(); } @@ -550,7 +550,7 @@ public static class ChatArchiveImporter } ChatThread? thread; - await using (var threadStream = threadEntry.Entry.Open()) + await using (var threadStream = await threadEntry.Entry.OpenAsync(token)) thread = await JsonSerializer.DeserializeAsync(threadStream, WorkspaceBehaviour.JSON_OPTIONS, token); if (thread is null) diff --git a/app/MindWork AI Studio/Tools/DataBackup/DataBackupReader.cs b/app/MindWork AI Studio/Tools/DataBackup/DataBackupReader.cs index a6b08dd71..5199d8508 100644 --- a/app/MindWork AI Studio/Tools/DataBackup/DataBackupReader.cs +++ b/app/MindWork AI Studio/Tools/DataBackup/DataBackupReader.cs @@ -31,7 +31,7 @@ public static class DataBackupReader DataBackupManifest? manifest; try { - await using var manifestStream = manifestEntry.Open(); + await using var manifestStream = await manifestEntry.OpenAsync(token); manifest = await JsonSerializer.DeserializeAsync(manifestStream, DataBackupFormat.JSON_OPTIONS, token); } catch (JsonException) diff --git a/app/MindWork AI Studio/Tools/DataBackup/DataBackupWriter.cs b/app/MindWork AI Studio/Tools/DataBackup/DataBackupWriter.cs index 9df7c239a..bee9361a8 100644 --- a/app/MindWork AI Studio/Tools/DataBackup/DataBackupWriter.cs +++ b/app/MindWork AI Studio/Tools/DataBackup/DataBackupWriter.cs @@ -50,7 +50,7 @@ public static class DataBackupWriter var buffer = new byte[BUFFER_SIZE]; progress?.Report(new(DataBackupPhase.WRITING, 0, totalFiles)); - using (var archive = new ZipArchive(target, ZipArchiveMode.Create, leaveOpen: true)) + await using (var archive = await ZipArchive.CreateAsync(target, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: null, token)) { var settingsEntry = DataBackupContents.ENTRIES.First(entry => entry.Kind is DataBackupEntryKind.SETTINGS); await using (var settingsStream = new MemoryStream(Encoding.UTF8.GetBytes(sources.SettingsJson), writable: false)) @@ -179,7 +179,7 @@ public static class DataBackupWriter using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); long size = 0; - await using (var entryStream = entry.Open()) + await using (var entryStream = await entry.OpenAsync(token)) { int read; while ((read = await source.ReadAsync(buffer, token)) > 0) diff --git a/app/MindWork AI Studio/Tools/DataBackup/DataRestorePreparer.cs b/app/MindWork AI Studio/Tools/DataBackup/DataRestorePreparer.cs index 3c9e3b062..bbabf855d 100644 --- a/app/MindWork AI Studio/Tools/DataBackup/DataRestorePreparer.cs +++ b/app/MindWork AI Studio/Tools/DataBackup/DataRestorePreparer.cs @@ -47,7 +47,7 @@ public static class DataRestorePreparer Discard(target); try { - using var archive = ZipFile.OpenRead(archivePath); + await using var archive = await ZipFile.OpenReadAsync(archivePath, token); var manifestResult = await DataBackupReader.ReadManifestAsync(archive, token); if (manifestResult.Manifest is not { } manifest) return new(false, false, manifestResult.Issue); @@ -218,7 +218,7 @@ public static class DataRestorePreparer using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); long size = 0; - await using (var source = file.Entry.Open()) + await using (var source = await file.Entry.OpenAsync(token)) await using (var destination = new FileStream(file.DestinationPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous | FileOptions.SequentialScan)) { int read; diff --git a/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs b/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs index 9b616d85f..acc4194ad 100644 --- a/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs +++ b/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs @@ -8,7 +8,15 @@ namespace AIStudio.Tools.ERIClient; public abstract class ERIClientBase(IERIDataSource dataSource) : IDisposable { protected readonly IERIDataSource DataSource = dataSource; - + + /// + /// How AI Studio writes its requests to an ERI server and reads the answers. + /// + /// + /// An answer which names a field twice is refused, also when the two names differ only in case: + /// AI Studio would follow one of the values, such as the provider types a data source allows, while + /// the server may have meant the other. + /// internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions { WriteIndented = true, @@ -17,6 +25,7 @@ public abstract class ERIClientBase(IERIDataSource dataSource) : IDisposable PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + AllowDuplicateProperties = false, Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseUpper), diff --git a/app/MindWork AI Studio/Tools/FileExtractionResult.cs b/app/MindWork AI Studio/Tools/FileExtractionResult.cs index 855128e68..359c74edc 100644 --- a/app/MindWork AI Studio/Tools/FileExtractionResult.cs +++ b/app/MindWork AI Studio/Tools/FileExtractionResult.cs @@ -21,8 +21,6 @@ public readonly record struct FileExtractionResult(FileExtractionOutcome Outcome private static readonly int[] NO_FAILED_PAGES = []; private static readonly PromptInjectionFinding[] NO_FINDINGS = []; - private readonly IReadOnlyList? promptInjectionFindings; - /// /// The prompt-injection attempts the runtime filtered out of the content, if any. /// @@ -33,8 +31,8 @@ public readonly record struct FileExtractionResult(FileExtractionOutcome Outcome /// public IReadOnlyList PromptInjectionFindings { - get => this.promptInjectionFindings ?? NO_FINDINGS; - init => this.promptInjectionFindings = value; + get => field ?? NO_FINDINGS; + init; } /// diff --git a/app/MindWork AI Studio/Tools/Pandoc.cs b/app/MindWork AI Studio/Tools/Pandoc.cs index f060c37ff..b5c7de92c 100644 --- a/app/MindWork AI Studio/Tools/Pandoc.cs +++ b/app/MindWork AI Studio/Tools/Pandoc.cs @@ -196,11 +196,7 @@ public static partial class Pandoc if (uri.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) { await RunWithRetriesAsync( - () => - { - ZipFile.ExtractToDirectory(pandocTempDownloadFile, stagingDir, true); - return Task.CompletedTask; - }, + () => ZipFile.ExtractToDirectoryAsync(pandocTempDownloadFile, stagingDir, true), "extracting the Pandoc ZIP archive"); } else if (uri.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginArchive.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginArchive.cs index 30424c844..4361afe96 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginArchive.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginArchive.cs @@ -17,9 +17,9 @@ public static class PluginArchive // Compatibility shim for Windows-created ZIPs with backslashes in entry names (dotnet/runtime#27620); // remove after dotnet/runtime#27620 and #41914 are fixed. // See documentation/compatibility-shims/2026-07-plugin-archive-zip-backslashes.md. - public static void Extract(string sourceArchiveFileName, string destinationDirectory) + public static async Task ExtractAsync(string sourceArchiveFileName, string destinationDirectory, CancellationToken token = default) { - using var archive = ZipFile.OpenRead(sourceArchiveFileName); + await using var archive = await ZipFile.OpenReadAsync(sourceArchiveFileName, token); Directory.CreateDirectory(destinationDirectory); foreach (var entry in archive.Entries) @@ -37,7 +37,7 @@ public static class PluginArchive } Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); - entry.ExtractToFile(destinationPath); + await entry.ExtractToFileAsync(destinationPath, token); } } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs index 872294192..14b19fb80 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs @@ -64,7 +64,7 @@ public static partial class PluginFactory await response.Content.CopyToAsync(tempFileStream, cancellationToken); } - ExtractConfigPluginArchive(tempDownloadFile, stagedDirectory); + await ExtractConfigPluginArchiveAsync(tempDownloadFile, stagedDirectory, cancellationToken); var configDirectory = Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString()); if (Directory.Exists(configDirectory)) @@ -129,9 +129,9 @@ public static partial class PluginFactory return wasSuccessful; } - private static void ExtractConfigPluginArchive(string sourceArchiveFileName, string destinationDirectory) + private static async Task ExtractConfigPluginArchiveAsync(string sourceArchiveFileName, string destinationDirectory, CancellationToken token) { - PluginArchive.Extract(sourceArchiveFileName, destinationDirectory); + await PluginArchive.ExtractAsync(sourceArchiveFileName, destinationDirectory, token); if (!Directory.EnumerateFiles(destinationDirectory, "plugin.lua", SearchOption.AllDirectories).Any()) throw new InvalidDataException("The enterprise configuration plugin archive does not contain a plugin.lua file."); diff --git a/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs b/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs index 6ac6e277b..1989d237d 100644 --- a/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs +++ b/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs @@ -20,7 +20,7 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv } private readonly SemaphoreSlim registrationSemaphore = new(1, 1); - private readonly object runtimeStateLock = new(); + private readonly Lock runtimeStateLock = new(); private readonly Dictionary lastSentStates = []; private readonly Dictionary lastNonEmptyShortcuts = []; private readonly Dictionary runtimeBindings = []; diff --git a/app/MindWork AI Studio/Tools/Services/Indexing/FileSourceIndexer.Watchers.cs b/app/MindWork AI Studio/Tools/Services/Indexing/FileSourceIndexer.Watchers.cs index 2a5143268..774ed9fa9 100644 --- a/app/MindWork AI Studio/Tools/Services/Indexing/FileSourceIndexer.Watchers.cs +++ b/app/MindWork AI Studio/Tools/Services/Indexing/FileSourceIndexer.Watchers.cs @@ -12,7 +12,7 @@ internal sealed partial class FileSourceIndexer private readonly ConcurrentDictionary watchers = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary watcherDebounceTokens = new(StringComparer.OrdinalIgnoreCase); - private readonly object watcherDebounceLock = new(); + private readonly Lock watcherDebounceLock = new(); /// /// The data sources handed in last, by their id. A watcher which failed is created anew from here. diff --git a/app/MindWork AI Studio/Tools/Services/PluginInstallService.Import.cs b/app/MindWork AI Studio/Tools/Services/PluginInstallService.Import.cs index a32455ce1..1297216ae 100644 --- a/app/MindWork AI Studio/Tools/Services/PluginInstallService.Import.cs +++ b/app/MindWork AI Studio/Tools/Services/PluginInstallService.Import.cs @@ -45,7 +45,7 @@ public sealed partial class PluginInstallService if (!this.TryCreateStagingDirectory(PLUGIN_IMPORT_DIRECTORY_PREFIX, out stagingDirectory, out var stagingIssue)) return Error(stagingIssue); - PluginArchive.Extract(archivePath, stagingDirectory); + await PluginArchive.ExtractAsync(archivePath, stagingDirectory, token); var pluginFiles = Directory.EnumerateFiles(stagingDirectory, PLUGIN_FILE_NAME, SearchOption.AllDirectories).ToArray(); if (pluginFiles.Length != 1) diff --git a/app/MindWork AI Studio/Tools/Services/PluginShareService.cs b/app/MindWork AI Studio/Tools/Services/PluginShareService.cs index 77dde1697..c37848b1c 100644 --- a/app/MindWork AI Studio/Tools/Services/PluginShareService.cs +++ b/app/MindWork AI Studio/Tools/Services/PluginShareService.cs @@ -140,7 +140,7 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru // one next to it, which is what deleting and recreating would come down to. // await using var archiveStream = File.Create(archivePath); - ZipFile.CreateFromDirectory(pluginRoot, archiveStream, CompressionLevel.Optimal, false); + await ZipFile.CreateFromDirectoryAsync(pluginRoot, archiveStream, CompressionLevel.Optimal, false, token); }, token); logger.LogInformation("Exported plugin '{PluginName}' ({PluginId}) to the archive '{ArchivePath}'.", plugin.Name, plugin.Id, archivePath); @@ -177,10 +177,10 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru Directory.CreateDirectory(archiveDirectory); this.CleanUpExpiredArchives(archiveDirectory); - await Task.Run(() => + await Task.Run(async () => { token.ThrowIfCancellationRequested(); - ZipFile.CreateFromDirectory(pluginRoot, archivePath, CompressionLevel.Optimal, false); + await ZipFile.CreateFromDirectoryAsync(pluginRoot, archivePath, CompressionLevel.Optimal, false, token); }, token); token.ThrowIfCancellationRequested(); diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolExecutor.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolExecutor.cs index ccdf29154..02bff5faf 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolExecutor.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolExecutor.cs @@ -14,6 +14,22 @@ public sealed class ToolExecutor(ToolSettingsService toolSettingsService, ILogge { private const string INVALID_TOOL_CALL_ERROR = "The tool call was invalid."; + /// + /// How the arguments are read for running a tool: every name only once per object. + /// + /// + /// JSON allows a name twice, and readers disagree on which of the values counts. A tool reads one + /// of them, while the check for addresses the model wrote into its call reads both, and a provider + /// which receives the call back may read the other. A call which names an argument twice therefore + /// does not run, and the message of the parser, which names the argument but quotes none of the + /// values, goes back to the model. The trace reads the arguments without this rule, so that the + /// user still sees what the model sent. + /// + private static readonly JsonDocumentOptions EXECUTION_ARGUMENT_OPTIONS = new() + { + AllowDuplicateProperties = false, + }; + public ToolCallOutcome CreateInvalidToolCallResult(string toolCallId, int order) { logger.LogWarning( @@ -116,7 +132,7 @@ public sealed class ToolExecutor(ToolSettingsService toolSettingsService, ILogge var implementation = runnableTool.Implementation; try { - using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson, EXECUTION_ARGUMENT_OPTIONS); var settingsManager = Program.SERVICE_PROVIDER.GetRequiredService(); // diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md index 8d8a6ffa6..13c09d14e 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md @@ -9,6 +9,8 @@ - Improved how reliably AI Studio exchanges data with AI providers, external data sources, and web search services. - Improved how reliably the Visual Briefing assistant stores your briefings, and how reliably the assistants of AI Studio read the answers of AI models. - Improved how AI Studio colors code blocks: it now highlights them itself instead of through an extra script, which makes the app smaller. This works for Python, JavaScript, TypeScript, Java, C, C++, C#, Go, Kotlin, Rust, SQL, shell scripts, PowerShell, Lua, CSS, HTML, XML, JSON, and YAML; code in other languages, such as Ruby or PHP, appears without colors. When a code block names no language, AI Studio recognizes it from the code, colors the block, and suggests the matching file extension when you save it. +- Improved how carefully AI Studio reads the answers of AI models and external data sources: an answer that contains the same entry twice, possibly with two different values, is now refused instead of AI Studio picking one of them. This covers tool calls, the Assistant Builder, the Visual Briefing assistant, and ERI servers. +- Improved the order of the models when you add or edit a provider: AI Studio now sorts the numbers in model names by their value, so that, for example, a model with version 4.10 comes before one with version 4.9, and the newest models stay at the top of the list. - Changed the size of AI Studio on laptop screens: it now appears in the same size on every laptop, whatever the scaling of your operating system. If you prefer the previous size, choose "System scaling" for the interface size in the app settings. Contributed by Paul Koudelka. - Fixed AI Studio becoming unusable when an update failed after its download. The app now restarts with the version you have installed, so you can simply try the update again. - Fixed configuration plugins you installed yourself being able to change the tool settings of your organization, such as the minimum provider confidence of a tool. These settings now follow the same rules as all other settings, which also gives you back your own values once your organization removes its configuration. @@ -22,6 +24,7 @@ - Fixed saving a code block as a file, where the last line could end differently from all other lines, for example, on Windows. All lines of the file now end the same way. - Fixed the result of a tool in the chat showing umlauts, quotation marks, and other special characters as codes instead of the characters themselves. - Fixed the Assistant Builder telling you in English why it could not use the answer of the AI model, even when you chose another language for AI Studio. +- Fixed requests to a provider failing when a configuration plugin gave it additional API parameters that contain the same parameter twice. AI Studio now sends the request without these parameters and names the repeated parameter in its log, as it does for other invalid parameters. - Upgraded several libraries to improve security. - Upgraded Tauri to v2.12.5. - Upgraded MudBlazor to v9.12.0. diff --git a/app/Tests/Analyzers/ThisUsageAnalyzerTests.cs b/app/Tests/Analyzers/ThisUsageAnalyzerTests.cs index b8dd8e699..b383d25fc 100644 --- a/app/Tests/Analyzers/ThisUsageAnalyzerTests.cs +++ b/app/Tests/Analyzers/ThisUsageAnalyzerTests.cs @@ -15,6 +15,9 @@ namespace AIStudio.Tests.Analyzers; /// refuses the keyword. There, every instance member is reached through the name of the receiver, and a sibling /// member without it does not even compile. Should a later version of C# reach the receiver without its name, /// the analyzer would demand a this. which nobody can write, and the tests for extension blocks fail. +/// +/// The same holds for the keyword field of a property: its backing field has no name which could follow +/// this., while every other member next to it still needs one. /// [TestFixture] public sealed class ThisUsageAnalyzerTests @@ -46,6 +49,28 @@ public sealed class ThisUsageAnalyzerTests } } + [TestCase("get => field ?? this.Name; set => field = value;", TestName = "A getter and a setter which reach the backing field")] + [TestCase("get; set => field = value.Trim();", TestName = "A setter which reaches the backing field")] + [TestCase("get => field ??= this.Describe();", TestName = "A getter which fills the backing field on first use")] + public async Task TheBackingFieldOfAPropertyIsLeftAlone(string accessors) + { + var reported = await AnalyzePropertyAsync(accessors); + + Assert.That(reported, Is.Empty); + } + + [Test] + public async Task AMemberNextToTheBackingFieldStillNeedsThis() + { + var reported = await AnalyzePropertyAsync("get => field ?? Name; set => field = value;"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(reported, Has.Count.EqualTo(1)); + Assert.That(reported.FirstOrDefault()?.Id, Is.EqualTo("MWAIS0005")); + } + } + [Test] public async Task AClassicExtensionMethodIsLeftAlone() { @@ -140,4 +165,23 @@ public sealed class ThisUsageAnalyzerTests return await CompilationHarness.AnalyzeAsync(source, new ThisUsageAnalyzer()); } + + private static async Task> AnalyzePropertyAsync(string accessors) + { + var source = + $$""" + namespace Sample; + + public sealed class Meeting + { + public string Name { get; set; } = "Kickoff"; + + public string Title { {{accessors}} } + + private string Describe() => this.Name; + } + """; + + return await CompilationHarness.AnalyzeAsync(source, new ThisUsageAnalyzer()); + } } diff --git a/app/Tests/Assistants/VisualBriefing/VisualBriefingDuplicateFieldTests.cs b/app/Tests/Assistants/VisualBriefing/VisualBriefingDuplicateFieldTests.cs new file mode 100644 index 000000000..d63661a0f --- /dev/null +++ b/app/Tests/Assistants/VisualBriefing/VisualBriefingDuplicateFieldTests.cs @@ -0,0 +1,44 @@ +using AIStudio.Assistants.VisualBriefing; + +namespace AIStudio.Tests.Assistants.VisualBriefing; + +/// +/// Checks that the Visual Briefing assistant refuses a model answer which names a field twice. +/// +/// +/// The checks of the assistant would see one of the two values, while the browser which runs the +/// finished briefing may see the other. The repair turn names the field, so that the model can fix it. +/// +[TestFixture] +public sealed class VisualBriefingDuplicateFieldTests +{ + [TestCase("""{"contractVersion":1,"contractVersion":2}""", "contractVersion", "$.contractVersion", TestName = "A field of the answer")] + [TestCase("""{"contractVersion":1,"facts":[{"id":"fact-1","id":"fact-2"}]}""", "id", "$.facts[0].id", TestName = "A field of an object in a list")] + [TestCase("""{"facts":[{"value":{"unit":"EUR","unit":"USD"}}]}""", "unit", "$.facts[0].value.unit", TestName = "A field deep inside a value")] + [TestCase("```json\n{\"contractVersion\":1,\"contractVersion\":2}\n```", "contractVersion", "$.contractVersion", TestName = "A field of an answer in a code block")] + public void AnAnswerWhichNamesAFieldTwiceIsRefused(string answer, string repeatedField, string path) + { + var result = VisualBriefingStructuredResponseProcessor.Process(answer, _ => null); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Response, Is.Null); + Assert.That(result.Issue?.Code, Is.EqualTo(VisualBriefingFailureCode.RESPONSE_JSON_INVALID)); + Assert.That(result.Issue?.Rule, Is.EqualTo(VisualBriefingValidationRule.JSON_INVALID)); + Assert.That(result.Issue?.Diagnostic?.IssueKind, Is.EqualTo(VisualBriefingStructuredResponseIssueKind.DUPLICATE_FIELD)); + Assert.That(result.Issue?.Diagnostic?.FieldName, Is.EqualTo(repeatedField)); + Assert.That(result.Issue?.Diagnostic?.JsonPath, Is.EqualTo(path)); + Assert.That(result.Issue?.Issue, Does.Contain($"'{repeatedField}'"), "The repair turn names the field."); + } + } + + [Test] + public void TheSameFieldInTwoObjectsIsNoDuplicate() + { + var result = VisualBriefingStructuredResponseProcessor.Process( + """{"contractVersion":1,"facts":[{"id":"fact-1"},{"id":"fact-2"}]}""", + _ => null); + + Assert.That(result.Issue?.Diagnostic?.IssueKind, Is.Not.EqualTo(VisualBriefingStructuredResponseIssueKind.DUPLICATE_FIELD)); + } +} \ No newline at end of file diff --git a/app/Tests/Provider/AdditionalApiParametersParserTests.cs b/app/Tests/Provider/AdditionalApiParametersParserTests.cs new file mode 100644 index 000000000..e8b2ec48e --- /dev/null +++ b/app/Tests/Provider/AdditionalApiParametersParserTests.cs @@ -0,0 +1,46 @@ +using AIStudio.Provider; + +namespace AIStudio.Tests.Provider; + +/// +/// Checks how the additional API parameters of a provider are read. +/// +/// +/// The dialog for providers refuses a parameter named twice, but a configuration plugin brings its +/// providers past the dialog. Read anyway, such parameters would leave it open which of the values +/// reaches the provider, and they used to break every request with an exception nobody caught. +/// +[TestFixture] +public sealed class AdditionalApiParametersParserTests +{ + [Test] + public void TheParametersAreRead() + { + var isRead = AdditionalApiParametersParser.TryParse(""" + "temperature": 0.5, + "reasoning": { "effort": "low" } + """, out var parameters, out var errorMessage); + + using (Assert.EnterMultipleScope()) + { + Assert.That(isRead, Is.True); + Assert.That(errorMessage, Is.Null); + Assert.That(parameters["temperature"], Is.EqualTo(0.5)); + Assert.That(parameters["reasoning"], Is.EqualTo(new Dictionary { ["effort"] = "low" })); + } + } + + [TestCase("\"temperature\": 0.5, \"temperature\": 0.7", "temperature", TestName = "A parameter named twice")] + [TestCase("\"reasoning\": { \"effort\": \"low\", \"effort\": \"high\" }", "effort", TestName = "A field named twice inside a parameter")] + public void AParameterNamedTwiceIsRefused(string additionalParameters, string repeatedName) + { + var isRead = AdditionalApiParametersParser.TryParse(additionalParameters, out var parameters, out var errorMessage); + + using (Assert.EnterMultipleScope()) + { + Assert.That(isRead, Is.False); + Assert.That(parameters, Is.Empty); + Assert.That(errorMessage, Does.Contain($"'{repeatedName}'"), "The log names the parameter to correct."); + } + } +} \ No newline at end of file diff --git a/app/Tests/Provider/ModelIdOrderTests.cs b/app/Tests/Provider/ModelIdOrderTests.cs new file mode 100644 index 000000000..bc0d204e4 --- /dev/null +++ b/app/Tests/Provider/ModelIdOrderTests.cs @@ -0,0 +1,25 @@ +using AIStudio.Provider; + +namespace AIStudio.Tests.Provider; + +/// +/// Checks the order in which the dialogs for providers list the models, newest first. +/// +/// +/// Compared digit by digit, "gpt-4.9" would come before "gpt-4.10" and "o3" before "o10", so an older +/// model would be on top of the list. +/// +[TestFixture] +public sealed class ModelIdOrderTests +{ + [TestCase(new[] { "gpt-4.9", "gpt-4.10", "gpt-4.1" }, new[] { "gpt-4.10", "gpt-4.9", "gpt-4.1" }, TestName = "Version numbers after a dot")] + [TestCase(new[] { "o3", "o10", "o1" }, new[] { "o10", "o3", "o1" }, TestName = "Numbers after a letter")] + [TestCase(new[] { "llama3.1:8b", "llama3.1:70b" }, new[] { "llama3.1:70b", "llama3.1:8b" }, TestName = "Sizes of a model")] + [TestCase(new[] { "mistral-large-2407", "mistral-large-2411", "mistral-large-latest" }, new[] { "mistral-large-latest", "mistral-large-2411", "mistral-large-2407" }, TestName = "Dates of a model")] + public void TheNewestModelsComeFirst(string[] modelIds, string[] expectedOrder) + { + var ordered = modelIds.OrderByDescending(id => id, Model.ID_ORDER); + + Assert.That(ordered, Is.EqualTo(expectedOrder)); + } +} \ No newline at end of file diff --git a/app/Tests/Serialization/DuplicatePropertyTests.cs b/app/Tests/Serialization/DuplicatePropertyTests.cs new file mode 100644 index 000000000..3cf68ab1c --- /dev/null +++ b/app/Tests/Serialization/DuplicatePropertyTests.cs @@ -0,0 +1,67 @@ +using System.Text.Json; + +using AIStudio.Assistants.Builder; +using AIStudio.Assistants.VisualBriefing; +using AIStudio.Tools.ERIClient; +using AIStudio.Tools.ERIClient.DataModel; + +namespace AIStudio.Tests.Serialization; + +/// +/// Checks that JSON which a model or a server wrote may name a field only once. +/// +/// +/// JSON allows a name twice, and readers disagree on which of the values counts. Whatever AI Studio +/// checks in such an answer, another reader may see the other value, so these options refuse it. The +/// stored data, such as the settings, stays out of this: AI Studio wrote it itself, and refusing it +/// would lose it. +/// +[TestFixture] +public sealed class DuplicatePropertyTests +{ + private static IEnumerable OptionsForAnswersFromOutside() + { + yield return new TestCaseData(LuaResponse.JSON_OPTIONS).SetName("The plugins the Assistant Builder writes"); + yield return new TestCaseData(LauncherTextsResponse.JSON_OPTIONS).SetName("The texts the Assistant Builder writes for a chat launcher"); + yield return new TestCaseData(VisualBriefingJson.Canonical).SetName("The answers of the model for a visual briefing"); + yield return new TestCaseData(ERIClientBase.JSON_OPTIONS).SetName("The answers of ERI servers"); + } + + [TestCaseSource(nameof(OptionsForAnswersFromOutside))] + public void TheOptionsRefuseAFieldNamedTwice(JsonSerializerOptions options) => Assert.That(options.AllowDuplicateProperties, Is.False); + + [Test] + public void APluginWhoseLuaCodeIsNamedTwiceIsRefused() + { + var isParsed = LuaResponse.TryParse("""{"full_lua":"ID = \"a\"","full_lua":"ID = \"b\""}""", out _, out var error, out var technicalDetails); + + using (Assert.EnterMultipleScope()) + { + Assert.That(isParsed, Is.False); + Assert.That(error, Is.EqualTo(LuaResponseParseError.INVALID_JSON)); + Assert.That(technicalDetails, Does.Contain("'full_lua'"), "The user learns which field the model wrote twice."); + } + } + + [Test] + public void TextsForAChatLauncherWithATitleNamedTwiceAreRefused() + { + var isParsed = LauncherTextsResponse.TryParse("""{"title":"Budget","title":"Travel"}""", out _, out var error, out var technicalDetails); + + using (Assert.EnterMultipleScope()) + { + Assert.That(isParsed, Is.False); + Assert.That(error, Is.EqualTo(LuaResponseParseError.INVALID_JSON)); + Assert.That(technicalDetails, Does.Contain("'title'"), "The user learns which field the model wrote twice."); + } + } + + [TestCase("""{"allowedProviderType":"SELF_HOSTED","allowedProviderType":"ANY"}""", TestName = "The same name twice")] + [TestCase("""{"allowedProviderType":"SELF_HOSTED","AllowedProviderType":"ANY"}""", TestName = "Two names which differ only in case")] + public void SecurityRequirementsOfAnERIServerWhichNameAFieldTwiceAreRefused(string answer) + { + var exception = Assert.Throws(() => JsonSerializer.Deserialize(answer, ERIClientBase.JSON_OPTIONS)); + + Assert.That(exception.Message, Does.Contain("AllowedProviderType").IgnoreCase, "The message names the field the server wrote twice."); + } +} \ No newline at end of file diff --git a/app/Tests/Tools/PluginSystem/PluginArchiveTests.cs b/app/Tests/Tools/PluginSystem/PluginArchiveTests.cs new file mode 100644 index 000000000..820a818ad --- /dev/null +++ b/app/Tests/Tools/PluginSystem/PluginArchiveTests.cs @@ -0,0 +1,101 @@ +using System.IO.Compression; + +using AIStudio.Tools.PluginSystem; + +namespace AIStudio.Tests.Tools.PluginSystem; + +/// +/// Checks how plugin archives are unpacked, from an import or from the configuration server of an organization. +/// +/// +/// AI Studio unpacks these archives itself rather than through ZipFile.ExtractToDirectory, which on Linux and +/// macOS would keep a backslash in an entry name as part of the file name. Archives packed on Windows by hand +/// or with another tool hold such names, and the plugin.lua inside would never be found. +/// +[TestFixture] +public sealed class PluginArchiveTests +{ + private string testDirectory = string.Empty; + + [SetUp] + public void CreateTestDirectory() + { + this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-plugin-archive-{Guid.NewGuid():N}"); + Directory.CreateDirectory(this.testDirectory); + } + + [TearDown] + public void DeleteTestDirectory() + { + if (Directory.Exists(this.testDirectory)) + Directory.Delete(this.testDirectory, true); + } + + [Test] + public async Task AnArchivePackedOnWindowsIsUnpackedIntoItsFolders() + { + var archivePath = await this.CreateArchiveAsync(new ArchiveEntry("O\\plugin.lua", "ID = \"o\""), new ArchiveEntry("O\\lib\\"), new ArchiveEntry("O\\lib\\helpers.lua", "return {}")); + var destination = Path.Combine(this.testDirectory, "unpacked"); + + await PluginArchive.ExtractAsync(archivePath, destination); + + using (Assert.EnterMultipleScope()) + { + Assert.That(await File.ReadAllTextAsync(Path.Combine(destination, "O", "plugin.lua")), Is.EqualTo("ID = \"o\"")); + Assert.That(await File.ReadAllTextAsync(Path.Combine(destination, "O", "lib", "helpers.lua")), Is.EqualTo("return {}")); + } + } + + [TestCase("../plugin.lua", TestName = "An entry above the destination")] + [TestCase("/plugin.lua", TestName = "An entry with an absolute path")] + public async Task AnEntryOutsideOfTheDestinationIsRefused(string entryName) + { + var archivePath = await this.CreateArchiveAsync(new ArchiveEntry(entryName, "ID = \"o\"")); + var destination = Path.Combine(this.testDirectory, "unpacked"); + + await Assert.ThrowsAsync(() => PluginArchive.ExtractAsync(archivePath, destination)); + Assert.That(File.Exists(Path.Combine(this.testDirectory, "plugin.lua")), Is.False); + } + + [Test] + public async Task ADirectoryEntryWithDataIsRefused() + { + var archivePath = await this.CreateArchiveAsync(new ArchiveEntry("O/", "ID = \"o\"")); + + await Assert.ThrowsAsync(() => PluginArchive.ExtractAsync(archivePath, Path.Combine(this.testDirectory, "unpacked"))); + } + + [Test] + public async Task AnArchiveIsNotUnpackedAfterTheCancellation() + { + var archivePath = await this.CreateArchiveAsync(new ArchiveEntry("O/plugin.lua", "ID = \"o\"")); + var destination = Path.Combine(this.testDirectory, "unpacked"); + + await Assert.CatchAsync(() => PluginArchive.ExtractAsync(archivePath, destination, new CancellationToken(canceled: true))); + Assert.That(File.Exists(Path.Combine(destination, "O", "plugin.lua")), Is.False); + } + + private async Task CreateArchiveAsync(params ArchiveEntry[] entries) + { + var archivePath = Path.Combine(this.testDirectory, $"{Guid.NewGuid():N}.mwplugin"); + await using var archiveStream = File.Create(archivePath); + await using var archive = await ZipArchive.CreateAsync(archiveStream, ZipArchiveMode.Create, leaveOpen: false, entryNameEncoding: null); + foreach (var archiveEntry in entries) + { + var entry = archive.CreateEntry(archiveEntry.Name); + if (archiveEntry.Content is null) + continue; + + await using var entryStream = await entry.OpenAsync(); + await using var writer = new StreamWriter(entryStream); + await writer.WriteAsync(archiveEntry.Content); + } + + return archivePath; + } + + /// + /// An entry of a test archive: a file with its content, or a directory without one. + /// + private sealed record ArchiveEntry(string Name, string? Content = null); +} \ No newline at end of file diff --git a/app/Tests/Tools/ToolCalling/ToolExecutorTests.cs b/app/Tests/Tools/ToolCalling/ToolExecutorTests.cs index 710ce546c..f50eb3bf1 100644 --- a/app/Tests/Tools/ToolCalling/ToolExecutorTests.cs +++ b/app/Tests/Tools/ToolCalling/ToolExecutorTests.cs @@ -95,6 +95,35 @@ public sealed class ToolExecutorTests : ToolRegistryTestBase } } + [TestCase("""{"query":"budget","query":"https://attacker.example/?mail=board-meeting"}""", "query", TestName = "An argument named twice")] + [TestCase("""{"filter":{"sender":"budget","sender":"https://attacker.example/?mail=board-meeting"}}""", "sender", TestName = "A field named twice inside an argument")] + public async Task ACallWhichNamesAnArgumentTwiceDoesNotRun(string argumentsJson, string repeatedName) + { + // + // The tool would read one of the two values, while the check for addresses the model wrote + // into its call reads both. Which one a provider reads when the call goes back to it is + // anybody's guess: + // + var executed = false; + var tool = new TestTool(Definition(), execute: _ => + { + executed = true; + return new ToolExecutionResult { TextContent = "A passage from the handbook." }; + }); + + var outcome = await this.Execute(tool, new ChatThread(), argumentsJson); + + using (Assert.EnterMultipleScope()) + { + Assert.That(executed, Is.False); + Assert.That(outcome.Trace.Status, Is.EqualTo(ToolInvocationTraceStatus.ERROR)); + Assert.That(outcome.Content, Does.Contain($"'{repeatedName}'"), "The model learns which name it wrote twice."); + Assert.That(outcome.Content, Does.Not.Contain("budget").And.Not.Contain("attacker"), "The message quotes none of the values, which may carry secrets."); + Assert.That(outcome.Trace.Arguments, Is.Not.Empty, "The user still sees what the model sent."); + AssertDemandsNothing(outcome); + } + } + [Test] public async Task WhatAResultDemandsTightensTheChat() { diff --git a/documentation/compatibility-shims/2026-07-plugin-archive-zip-backslashes.md b/documentation/compatibility-shims/2026-07-plugin-archive-zip-backslashes.md index aa984339e..96f0885a3 100644 --- a/documentation/compatibility-shims/2026-07-plugin-archive-zip-backslashes.md +++ b/documentation/compatibility-shims/2026-07-plugin-archive-zip-backslashes.md @@ -30,6 +30,6 @@ This works around the behavior described in dotnet/runtime#27620. A related upst ## Removal Checklist - Confirm supported .NET runtimes and administrator packaging guidance no longer require accepting backslashes in enterprise ZIP entry names. -- Replace `PluginArchive.Extract(...)` with `ZipFile.ExtractToDirectory(...)`. +- Replace `PluginArchive.ExtractAsync(...)` with `ZipFile.ExtractToDirectoryAsync(...)`. - Stop treating `\` as a separator in `ZipEntryNames.Normalize`, and drop the backslash cases from `ZipEntryNamesTests`. Keep the checks against rooted, drive-qualified, and `..` names: they protect every extraction, not just this shim. - Update this document's status to `Removed`.