Fixed chats not loading in the v26.10.1 pre-release and added a trim smoke test (#1128)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-29 08:12:57 +01:00
1 parent 7b9e96d6a1
commit fd0a42fa8a
12 files changed
+346 -14

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
namespace AIStudio.TrimSmokeTest;
/// <summary>
/// Builds the contract System.Text.Json uses for a root and for everything it holds.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static class JsonContractWalker
{
/// <summary>
/// Returns a description of every contract which cannot be built, starting at the given root.
/// </summary>
/// <param name="root">The type to start with, and the options AI Studio uses for it.</param>
/// <returns>One line per failure, naming the path from the root to the type which failed.</returns>
public static IReadOnlyList<string> 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<string>();
Visit(root.Type, root.Type.Name, root.Options, [], failures);
return failures;
}
private static void Visit(Type type, string path, JsonSerializerOptions options, HashSet<Type> visited, List<string> 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);
}
}
+10
View File
@@ -0,0 +1,10 @@
using System.Text.Json;
namespace AIStudio.TrimSmokeTest;
/// <summary>
/// A type which AI Studio reads or writes as JSON on its own, together with the options it uses for that.
/// </summary>
/// <param name="Type">The type which gets serialized. Whatever it holds gets checked along with it.</param>
/// <param name="Options">The options AI Studio passes to System.Text.Json for this type.</param>
public sealed record JsonRoot(Type Type, JsonSerializerOptions Options);
+65
View File
@@ -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;
/// <summary>
/// The types which AI Studio reads or writes as JSON on their own, not only as part of another type.
/// </summary>
/// <remarks>
/// 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.<br/><br/>
///
/// 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.
/// </remarks>
public static class JsonRoots
{
public static readonly IReadOnlyList<JsonRoot> 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),
];
}
+64
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
public static class JsonRoundTrips
{
private const string TEXT_WITH_WEB_ADDRESS = "The details are on https://example.org/chosen.";
/// <summary>
/// Returns a description of every value which does not survive being written and read again.
/// </summary>
/// <returns>One line per failure, naming the type which failed.</returns>
public static IReadOnlyList<string> 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<string>();
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>(T value, JsonSerializerOptions options, List<string> failures)
{
try
{
var json = JsonSerializer.Serialize(value, options);
var jsonAgain = JsonSerializer.Serialize(JsonSerializer.Deserialize<T>(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}");
}
}
}
+34
View File
@@ -0,0 +1,34 @@
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.TrimSmokeTest;
/// <summary>
/// Checks whether a trimmed build can still read and write the JSON of AI Studio, see TrimSmokeTest.csproj.
/// </summary>
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<string>();
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;
}
}
+28
View File
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Checks what only a published build of AI Studio lacks. The trimmer removes code of .NET which System.Text.Json
reaches by reflection only, while debug builds and the tests are not trimmed. Hence, this program has to run
published, trimmed by the same Trimming.props as the app; `dotnet run verify` does both.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>AIStudio.TrimSmokeTest</RootNamespace>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<SelfContained>true</SelfContained> <!-- Trimming works for self-contained builds only -->
<!-- IL2026: Usage of methods marked as RequiresUnreferencedCode. Finding out what this means for a trimmed build is the whole point of this program. -->
<NoWarn>IL2026</NoWarn>
</PropertyGroup>
<Import Project="..\Trimming.props" />
<ItemGroup>
<ProjectReference Include="..\MindWork AI Studio\MindWork AI Studio.csproj" />
</ItemGroup>
</Project>