From fd0a42fa8abe43575874537e7b662a94708fe5eb Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Thu, 29 Oct 2026 08:12:57 +0100 Subject: [PATCH] Fixed chats not loading in the v26.10.1 pre-release and added a trim smoke test (#1128) --- app/Build/Commands/VerifyCommand.cs | 33 +++++++++- app/Build/Tools/Environment.cs | 7 ++ app/MindWork AI Studio.sln | 6 ++ .../MindWork AI Studio.csproj | 20 +++--- app/TrimSmokeTest/JsonContractWalker.cs | 61 +++++++++++++++++ app/TrimSmokeTest/JsonRoot.cs | 10 +++ app/TrimSmokeTest/JsonRoots.cs | 65 +++++++++++++++++++ app/TrimSmokeTest/JsonRoundTrips.cs | 64 ++++++++++++++++++ app/TrimSmokeTest/Program.cs | 34 ++++++++++ app/TrimSmokeTest/TrimSmokeTest.csproj | 28 ++++++++ app/Trimming.props | 27 ++++++++ documentation/Build.md | 5 +- 12 files changed, 346 insertions(+), 14 deletions(-) create mode 100644 app/TrimSmokeTest/JsonContractWalker.cs create mode 100644 app/TrimSmokeTest/JsonRoot.cs create mode 100644 app/TrimSmokeTest/JsonRoots.cs create mode 100644 app/TrimSmokeTest/JsonRoundTrips.cs create mode 100644 app/TrimSmokeTest/Program.cs create mode 100644 app/TrimSmokeTest/TrimSmokeTest.csproj create mode 100644 app/Trimming.props diff --git a/app/Build/Commands/VerifyCommand.cs b/app/Build/Commands/VerifyCommand.cs index 2f9b4e714..a8b37992a 100644 --- a/app/Build/Commands/VerifyCommand.cs +++ b/app/Build/Commands/VerifyCommand.cs @@ -1,5 +1,7 @@ using Build.Tools; +using SharedTools; + // ReSharper disable ClassNeverInstantiated.Global // ReSharper disable UnusedType.Global // ReSharper disable UnusedMember.Global @@ -20,7 +22,7 @@ public sealed class VerifyCommand /// private const string SIDECAR_PREFIX = "mindworkAIStudioServer-"; - [Command("verify", Description = "Run the quality gate: .NET tests, Rust tests, Clippy, and the model sources")] + [Command("verify", Description = "Run the quality gate: .NET tests, the trim smoke test, Rust tests, Clippy, and the model sources")] public async Task Verify() { if(!Environment.IsWorkingDirectoryValid()) @@ -32,6 +34,7 @@ public sealed class VerifyCommand var results = new List<(string What, int ExitCode)> { (".NET tests", await CommandRunner.RunAsync(Environment.GetTestsDirectory(), "dotnet", "test --nologo")), + ("Trim smoke test", await RunTrimSmokeTestAsync()), }; var runtimeDirectory = Environment.GetRustRuntimeDirectory(); @@ -71,6 +74,34 @@ public sealed class VerifyCommand return 1; } + /// + /// Publishes the trim smoke test for this machine, trimmed like the app, and runs it. + /// + /// + /// Debug builds and the tests are not trimmed, so only a published build shows that the trimmer + /// removed code which System.Text.Json reaches by reflection. Then AI Studio can neither load nor + /// save, e.g., its chats, while everything looks fine during development. + /// + /// The exit code of the publishing, when that failed; otherwise the one of the smoke test. + private static async Task RunTrimSmokeTestAsync() + { + var rid = Environment.GetCurrentRid(); + if (rid is RID.NONE) + { + Console.WriteLine("- Error: The trim smoke test needs a platform AI Studio is built for."); + return 1; + } + + var directory = Environment.GetTrimSmokeTestDirectory(); + var publishDirectory = Path.Combine(directory, "bin", "verify"); + var publishExitCode = await CommandRunner.RunAsync(directory, "dotnet", $"publish --configuration release --runtime {rid.AsMicrosoftRid()} --output \"{publishDirectory}\" --nologo"); + if (publishExitCode is not 0) + return publishExitCode; + + var program = Path.Combine(publishDirectory, OperatingSystem.IsWindows() ? "TrimSmokeTest.exe" : "TrimSmokeTest"); + return await CommandRunner.RunAsync(directory, program, string.Empty); + } + /// /// Whether a build has already produced the files Tauri's build script reads. /// diff --git a/app/Build/Tools/Environment.cs b/app/Build/Tools/Environment.cs index 4d77b9161..968b4e3f8 100644 --- a/app/Build/Tools/Environment.cs +++ b/app/Build/Tools/Environment.cs @@ -41,6 +41,13 @@ public static class Environment return Path.GetFullPath(directory); } + public static string GetTrimSmokeTestDirectory() + { + var currentDirectory = Directory.GetCurrentDirectory(); + var directory = Path.Combine(currentDirectory, "..", "TrimSmokeTest"); + return Path.GetFullPath(directory); + } + /// /// The root of the git repository, which is what a path in a report is written relative to. /// diff --git a/app/MindWork AI Studio.sln b/app/MindWork AI Studio.sln index 3666525c2..1464ac64a 100644 --- a/app/MindWork AI Studio.sln +++ b/app/MindWork AI Studio.sln @@ -12,6 +12,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceGeneratedMappings", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{CD46329B-D135-4594-9A70-55D3480F8FEE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrimSmokeTest", "TrimSmokeTest\TrimSmokeTest.csproj", "{0F4B3B3C-E8C9-4CDC-A247-5029D777DFC2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -42,6 +44,10 @@ Global {CD46329B-D135-4594-9A70-55D3480F8FEE}.Debug|Any CPU.Build.0 = Debug|Any CPU {CD46329B-D135-4594-9A70-55D3480F8FEE}.Release|Any CPU.ActiveCfg = Release|Any CPU {CD46329B-D135-4594-9A70-55D3480F8FEE}.Release|Any CPU.Build.0 = Release|Any CPU + {0F4B3B3C-E8C9-4CDC-A247-5029D777DFC2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0F4B3B3C-E8C9-4CDC-A247-5029D777DFC2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0F4B3B3C-E8C9-4CDC-A247-5029D777DFC2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0F4B3B3C-E8C9-4CDC-A247-5029D777DFC2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution EndGlobalSection diff --git a/app/MindWork AI Studio/MindWork AI Studio.csproj b/app/MindWork AI Studio/MindWork AI Studio.csproj index 497dd8681..05ad4cfb5 100644 --- a/app/MindWork AI Studio/MindWork AI Studio.csproj +++ b/app/MindWork AI Studio/MindWork AI Studio.csproj @@ -27,14 +27,6 @@ `dotnet publish -r` instead. --> osx-arm64;osx-x64;linux-x64;linux-arm64;win-x64;win-arm64 - - true - partial - true - false - false - true - true IL2026, CS8974 latest + + - + @@ -105,10 +101,12 @@ + diff --git a/app/TrimSmokeTest/JsonContractWalker.cs b/app/TrimSmokeTest/JsonContractWalker.cs new file mode 100644 index 000000000..73af51fb2 --- /dev/null +++ b/app/TrimSmokeTest/JsonContractWalker.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace AIStudio.TrimSmokeTest; + +/// +/// Builds the contract System.Text.Json uses for a root and for everything it holds. +/// +/// +/// A type whose contract needs code the trimmer removed fails right here, before any value is read or +/// written, just like it fails in AI Studio. A chat which lacks the property in question fails all the same. +/// +public static class JsonContractWalker +{ + /// + /// Returns a description of every contract which cannot be built, starting at the given root. + /// + /// The type to start with, and the options AI Studio uses for it. + /// One line per failure, naming the path from the root to the type which failed. + public static IReadOnlyList FindBrokenContracts(JsonRoot root) + { + // + // The same System.Text.Json does with the first call of Serialize or Deserialize: + // + root.Options.MakeReadOnly(populateMissingResolver: true); + + var failures = new List(); + Visit(root.Type, root.Type.Name, root.Options, [], failures); + return failures; + } + + private static void Visit(Type type, string path, JsonSerializerOptions options, HashSet visited, List failures) + { + if (!visited.Add(type)) + return; + + JsonTypeInfo typeInfo; + try + { + typeInfo = options.GetTypeInfo(type); + } + catch (Exception exception) when (exception is NotSupportedException or InvalidOperationException) + { + failures.Add($"{path}: {exception.Message}"); + return; + } + + foreach (var property in typeInfo.Properties) + Visit(property.PropertyType, $"{path} → {property.Name}", options, visited, failures); + + if (typeInfo.KeyType is { } keyType) + Visit(keyType, $"{path} → key", options, visited, failures); + + if (typeInfo.ElementType is { } elementType) + Visit(elementType, $"{path} → element", options, visited, failures); + + if (typeInfo.PolymorphismOptions is { } polymorphism) + foreach (var derivedType in polymorphism.DerivedTypes) + Visit(derivedType.DerivedType, $"{path} → {derivedType.DerivedType.Name}", options, visited, failures); + } +} \ No newline at end of file diff --git a/app/TrimSmokeTest/JsonRoot.cs b/app/TrimSmokeTest/JsonRoot.cs new file mode 100644 index 000000000..877f1e021 --- /dev/null +++ b/app/TrimSmokeTest/JsonRoot.cs @@ -0,0 +1,10 @@ +using System.Text.Json; + +namespace AIStudio.TrimSmokeTest; + +/// +/// A type which AI Studio reads or writes as JSON on its own, together with the options it uses for that. +/// +/// The type which gets serialized. Whatever it holds gets checked along with it. +/// The options AI Studio passes to System.Text.Json for this type. +public sealed record JsonRoot(Type Type, JsonSerializerOptions Options); \ No newline at end of file diff --git a/app/TrimSmokeTest/JsonRoots.cs b/app/TrimSmokeTest/JsonRoots.cs new file mode 100644 index 000000000..909f3d79b --- /dev/null +++ b/app/TrimSmokeTest/JsonRoots.cs @@ -0,0 +1,65 @@ +using System.Text.Json; + +using AIStudio.Agents; +using AIStudio.Agents.AssistantAudit; +using AIStudio.Assistants.Builder; +using AIStudio.Assistants.PromptOptimizer; +using AIStudio.Assistants.SWOTAnalysis; +using AIStudio.Assistants.VisualBriefing; +using AIStudio.Chat; +using AIStudio.Settings; +using AIStudio.Settings.DataModel; +using AIStudio.Settings.DataModel.PreviousModels; +using AIStudio.Tools; +using AIStudio.Tools.DataBackup; + +namespace AIStudio.TrimSmokeTest; + +/// +/// The types which AI Studio reads or writes as JSON on their own, not only as part of another type. +/// +/// +/// Whatever these types hold, i.e., the types of their properties, the elements of their collections, and +/// their derived types, gets checked along with them, so only the outermost types belong here. Add a type +/// whenever AI Studio starts to store a new kind of file or to read a new kind of model answer.

+/// +/// The answers of models are read with options of their own, which are private and only change names and +/// leniency. The default options build the same contracts for the types of .NET they hold. A type which is +/// private to its parser, such as the DTO of a slide deck, is out of reach here. +///
+public static class JsonRoots +{ + public static readonly IReadOnlyList ALL = + [ + // Chats, in the workspaces, among the temporary chats, and in chat archives: + new(typeof(ChatThread), WorkspaceBehaviour.JSON_OPTIONS), + + // The settings, and their older versions, which the migrations still read: + new(typeof(Data), SettingsManager.JSON_OPTIONS), + new(typeof(DataV1V3), SettingsManager.JSON_OPTIONS), + new(typeof(DataV4), SettingsManager.JSON_OPTIONS), + new(typeof(DataV5), SettingsManager.JSON_OPTIONS), + + // The projects of the Visual Briefing assistant: + new(typeof(VisualBriefingManifest), VisualBriefingJson.Persistence), + new(typeof(VisualBriefingBuildRecord), VisualBriefingJson.Persistence), + new(typeof(VisualBriefingEvidenceArtifact), VisualBriefingJson.Persistence), + new(typeof(VisualBriefingPlanArtifact), VisualBriefingJson.Persistence), + new(typeof(VisualBriefingContentArtifact), VisualBriefingJson.Persistence), + new(typeof(VisualBriefingPresentationArtifact), VisualBriefingJson.Persistence), + + // Backups and their restore: + new(typeof(DataBackupManifest), DataBackupFormat.JSON_OPTIONS), + new(typeof(DataRestorePlan), DataBackupFormat.JSON_OPTIONS), + new(typeof(DataRestoreReport), DataBackupFormat.JSON_OPTIONS), + + // Answers of models: + new(typeof(LuaResponse), JsonSerializerOptions.Default), + new(typeof(LauncherTextsResponse), JsonSerializerOptions.Default), + new(typeof(PromptOptimizationResult), JsonSerializerOptions.Default), + new(typeof(RetrievalContextValidationResult), JsonSerializerOptions.Default), + new(typeof(AssistantAuditResult), JsonSerializerOptions.Default), + new(typeof(SwotCategoryAnalysisResult), JsonSerializerOptions.Default), + new(typeof(SwotModerationResult), JsonSerializerOptions.Default), + ]; +} \ No newline at end of file diff --git a/app/TrimSmokeTest/JsonRoundTrips.cs b/app/TrimSmokeTest/JsonRoundTrips.cs new file mode 100644 index 000000000..43cca4479 --- /dev/null +++ b/app/TrimSmokeTest/JsonRoundTrips.cs @@ -0,0 +1,64 @@ +using System.Text.Json; + +using AIStudio.Assistants.VisualBriefing; +using AIStudio.Chat; +using AIStudio.Settings; +using AIStudio.Settings.DataModel; +using AIStudio.Tools; + +namespace AIStudio.TrimSmokeTest; + +/// +/// Writes and reads filled values of the roots which matter most, since some parts of a contract only come +/// into play once there is a value, e.g., the factory which builds an immutable collection. +/// +public static class JsonRoundTrips +{ + private const string TEXT_WITH_WEB_ADDRESS = "The details are on https://example.org/chosen."; + + /// + /// Returns a description of every value which does not survive being written and read again. + /// + /// One line per failure, naming the type which failed. + public static IReadOnlyList FindLosses() + { + var restrictions = ChatRestrictions.NONE.WithWebAddressesWrittenByModelIn(TEXT_WITH_WEB_ADDRESS); + var chat = new ChatThread + { + Name = "Trim smoke test", + Blocks = + [ + new ContentBlock + { + Time = DateTimeOffset.UnixEpoch, + ContentType = ContentType.TEXT, + Content = new ContentText { Text = TEXT_WITH_WEB_ADDRESS }, + Role = ChatRole.USER, + }, + ], + }; + + chat.RequireRestrictions(restrictions); + + var failures = new List(); + AddLoss(chat, WorkspaceBehaviour.JSON_OPTIONS, failures); + AddLoss(new Data(), SettingsManager.JSON_OPTIONS, failures); + AddLoss(new VisualBriefingManifest { Name = "Trim smoke test", Restrictions = restrictions }, VisualBriefingJson.Persistence, failures); + return failures; + } + + private static void AddLoss(T value, JsonSerializerOptions options, List failures) + { + try + { + var json = JsonSerializer.Serialize(value, options); + var jsonAgain = JsonSerializer.Serialize(JsonSerializer.Deserialize(json, options), options); + if (jsonAgain != json) + failures.Add($"{typeof(T).Name}: written again after reading, it gives other JSON than before."); + } + catch (Exception exception) when (exception is NotSupportedException or InvalidOperationException or JsonException) + { + failures.Add($"{typeof(T).Name}: {exception.Message}"); + } + } +} \ No newline at end of file diff --git a/app/TrimSmokeTest/Program.cs b/app/TrimSmokeTest/Program.cs new file mode 100644 index 000000000..3309fc329 --- /dev/null +++ b/app/TrimSmokeTest/Program.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace AIStudio.TrimSmokeTest; + +/// +/// Checks whether a trimmed build can still read and write the JSON of AI Studio, see TrimSmokeTest.csproj. +/// +public static class Program +{ + public static int Main() + { + // + // Several types of the app initialize a static logger from this factory, which only the start of + // the app assigns. Without it, they die in their type initializer, see app/Tests/TestHost.cs: + // + AIStudio.Program.LOGGER_FACTORY = NullLoggerFactory.Instance; + + var failures = new List(); + foreach (var root in JsonRoots.ALL) + failures.AddRange(JsonContractWalker.FindBrokenContracts(root)); + + failures.AddRange(JsonRoundTrips.FindLosses()); + if (failures.Count is 0) + { + Console.WriteLine($"- All {JsonRoots.ALL.Count} JSON roots can be read and written."); + return 0; + } + + foreach (var failure in failures) + Console.WriteLine($"- Error: {failure}"); + + return 1; + } +} \ No newline at end of file diff --git a/app/TrimSmokeTest/TrimSmokeTest.csproj b/app/TrimSmokeTest/TrimSmokeTest.csproj new file mode 100644 index 000000000..96f7e8c78 --- /dev/null +++ b/app/TrimSmokeTest/TrimSmokeTest.csproj @@ -0,0 +1,28 @@ + + + + + Exe + net9.0 + AIStudio.TrimSmokeTest + latest + enable + enable + false + true + + + IL2026 + + + + + + + + + \ No newline at end of file diff --git a/app/Trimming.props b/app/Trimming.props new file mode 100644 index 000000000..3989a339a --- /dev/null +++ b/app/Trimming.props @@ -0,0 +1,27 @@ + + + + + true + partial + true + false + false + true + true + + + + + + + + \ No newline at end of file diff --git a/documentation/Build.md b/documentation/Build.md index e918a0939..9a6e33a71 100644 --- a/documentation/Build.md +++ b/documentation/Build.md @@ -30,11 +30,12 @@ One command checks that what you are about to build is sound: 2. Navigate to the `/app/Build` directory within the repository. 3. Run `dotnet run verify`. -It runs the .NET tests, the Rust tests, Clippy (`cargo clippy --all-targets -- -D warnings`), and a report on the pages the model rules were written from. Every check runs, even after one of them has failed, so that a single run tells you everything that is wrong instead of the first thing. +It runs the .NET tests, the trim smoke test, the Rust tests, Clippy (`cargo clippy --all-targets -- -D warnings`), and a report on the pages the model rules were written from. Every check runs, even after one of them has failed, so that a single run tells you everything that is wrong instead of the first thing. `dotnet run build` runs the gate first and stops when it does not pass. For the quick loop while you are working on something, use `dotnet run build --skip-verify`, and let the gate run before you open a pull request. The same command runs in our GitHub workflow as the `verify` job, on every pull request which changes more than the documentation — including those without the `run-pipeline` label, because a gate which is closed exactly while nobody is looking is not a gate. -Two notes: +Three notes: +- The trim smoke test publishes `app/TrimSmokeTest` trimmed, exactly like the app, and reads and writes the JSON of chats, settings, Visual Briefing projects, backups, and model answers. Debug builds and the tests are not trimmed, so only a published build shows when the trimmer removed code which System.Text.Json needs; AI Studio can then neither load nor save that data. When AI Studio starts to store a new kind of file or to read a new kind of model answer, add its type to `JsonRoots` in `app/TrimSmokeTest`. - The Rust half of the gate needs the .NET sidecar (see "One-time mandatory steps" above). While that file is missing, the gate skips the Rust tests and Clippy and says so rather than failing, because the command which produces the sidecar is `dotnet run build` itself. - `dotnet run verify-models` reports how long ago somebody last read the pages behind the model rules, and names everything older than six months. It is a report and never a failure: a page nobody has looked at for a while is not a page which changed. Everything else about the model rules — whether two rules claim the same names, whether every family names a page and a day, whether every pattern is written the way model names arrive — is checked by the test project, and therefore by `dotnet test`.