From 7f72da628e8de3f941c28c001748da4c7d94092a Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 25 Oct 2026 21:09:23 +0100 Subject: [PATCH] Fixed assistants undoing your changes while a run you left continues (#1111) --- .../Assistants/AssistantBase.razor.cs | 11 +- .../SlideAssistant.razor.Presentation.cs | 15 +- .../SlideBuilder/SlideAssistant.razor.cs | 24 +- .../AssistantSessionSnapshot.cs | 19 ++ .../AssistantSessionSnapshotField.cs | 35 +++ .../IAssistantSessionSnapshotField.cs | 13 ++ .../wwwroot/changelog/v26.10.1.md | 1 + .../AssistantSessionSnapshotTests.cs | 210 ++++++++++++++++++ 8 files changed, 322 insertions(+), 6 deletions(-) create mode 100644 app/Tests/Tools/AssistantSessions/AssistantSessionSnapshotTests.cs diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs index 6403eb3d4..c285212b5 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs @@ -181,6 +181,7 @@ public abstract partial class AssistantBase : AssistantLowerBase wher private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForAssistant(this.assistantSessionKey); private Guid? assistantSessionId; private AssistantSessionSnapshot? pendingRenderedAssistantSessionSnapshot; + private AssistantSessionSnapshot? lastAttachedAssistantSessionSnapshot; /// /// Gets whether the Blazor component instance has already been disposed. @@ -1042,13 +1043,21 @@ public abstract partial class AssistantBase : AssistantLowerBase wher /// /// Applies an assistant session snapshot to this component instance. /// + /// + /// When the user leaves an assistant while it runs and comes back, this component only shows the run. + /// The component which started it keeps working out of sight and reports every few seconds, yet its + /// form stays as it was when the user left. So once this component has taken over a run, it takes over + /// only what the running assistant changed since, such as the progress and the result. What the user + /// changes here stays, just as it would in the component which started the run. + /// /// The snapshot to attach. /// Whether derived assistants should restore client-only UI state. /// A task that completes after the component was refreshed. private async Task AttachAssistantSession(AssistantSessionSnapshot snapshot, bool restoreClientOnlyContent) { this.assistantSessionId = snapshot.SessionId; - this.ImportAssistantSessionState(snapshot.State); + this.ImportAssistantSessionState(snapshot.StateChangedSince(this.lastAttachedAssistantSessionSnapshot)); + this.lastAttachedAssistantSessionSnapshot = snapshot; this.ChatThread = snapshot.ChatThread ?? this.ChatThread; this.IsProcessing = snapshot.IsActive; diff --git a/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.Presentation.cs b/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.Presentation.cs index 6832a62cb..b9e7631cb 100644 --- a/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.Presentation.cs +++ b/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.Presentation.cs @@ -217,7 +217,20 @@ public partial class SlideAssistant return; } - // A variant of another template means nothing for this one, nor do choices for its slides: + this.FitChoicesToTemplate(); + } + + /// + /// Fits the color variant and the choices for the slides to the loaded template. + /// + /// + /// A variant of another template means nothing for this one, nor do choices for its slides. + /// + private void FitChoicesToTemplate() + { + if (this.template is null) + return; + if (this.variantLabel is null || !this.template.VariantLabels.Contains(this.variantLabel)) this.variantLabel = this.template.VariantLabels.FirstOrDefault(); diff --git a/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.cs b/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.cs index 9cc3aeddd..3e8a30053 100644 --- a/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.cs @@ -346,6 +346,10 @@ public partial class SlideAssistant : AssistantBaseCore protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state) { + var previousTemplatePath = this.templatePath; + var previousSlideDeckAnswer = this.slideDeckAnswer; + var previousPictureLibraryPath = this.pictureLibraryPath; + state.Restore(INPUT_TITLE_STATE_KEY, value => this.inputTitle = value); state.Restore(INPUT_CONTENT_STATE_KEY, value => this.inputContent = value); state.Restore(CUSTOM_TARGET_LANGUAGE_STATE_KEY, value => this.customTargetLanguage = value); @@ -377,10 +381,22 @@ public partial class SlideAssistant : AssistantBaseCore this.planningStep = value); - // Neither the template nor the slides are kept themselves, only what they were read from: - this.LoadTemplate(); - this.RestoreSlideDeck(); - this.StartCountingPictureLibrary(); + // + // Neither the template nor the slides nor the count of the library are kept themselves, only + // what they were read from. A running session stores its state every few seconds, and the + // template is read on the thread of the renderer, maybe from a network drive. So each one is + // read again only when what it was read from has changed: + // + if (string.Equals(previousTemplatePath, this.templatePath, StringComparison.Ordinal)) + this.FitChoicesToTemplate(); + else + this.LoadTemplate(); + + if (!string.Equals(previousSlideDeckAnswer, this.slideDeckAnswer, StringComparison.Ordinal)) + this.RestoreSlideDeck(); + + if (!string.Equals(previousPictureLibraryPath, this.pictureLibraryPath, StringComparison.Ordinal)) + this.StartCountingPictureLibrary(); } #region Overrides of ComponentBase diff --git a/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionSnapshot.cs b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionSnapshot.cs index b5676df74..38faa41ca 100644 --- a/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionSnapshot.cs +++ b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionSnapshot.cs @@ -65,4 +65,23 @@ public sealed record AssistantSessionSnapshot /// Gets whether the session is still running or canceling. /// public bool IsActive => this.Status is AssistantSessionStatus.RUNNING or AssistantSessionStatus.CANCELING; + + /// + /// Gets the state fields which are new or hold another value than in an earlier snapshot of the same run. + /// + /// + /// A component which shows a run it did not start takes over only these, so that whatever the + /// running assistant left alone stays as the user has it in that component. + /// + /// The snapshot taken over last, or null for none. + /// The whole state when there is no earlier snapshot of the same run; otherwise, the new and changed fields. + public IReadOnlyDictionary StateChangedSince(AssistantSessionSnapshot? previous) + { + if (previous is null || previous.SessionId != this.SessionId) + return this.State; + + return this.State + .Where(field => !previous.State.TryGetValue(field.Key, out var previousField) || !previousField.HasSameValueAs(field.Value)) + .ToDictionary(field => field.Key, field => field.Value, StringComparer.Ordinal); + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionSnapshotField.cs b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionSnapshotField.cs index 053bfb116..69ce9d51f 100644 --- a/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionSnapshotField.cs +++ b/app/MindWork AI Studio/Tools/AssistantSessions/AssistantSessionSnapshotField.cs @@ -1,4 +1,6 @@ // ReSharper disable MemberCanBePrivate.Global +using System.Collections; + namespace AIStudio.Tools.AssistantSessions; /// @@ -42,4 +44,37 @@ public sealed record AssistantSessionSnapshotField : IAssistantSessionSnapsho value = default; return false; } + + /// + public bool HasSameValueAs(IAssistantSessionSnapshotField other) => other is AssistantSessionSnapshotField otherField && AreSame(this.Value, otherField.Value); + + private static bool AreSame(object? value, object? otherValue) + { + if (ReferenceEquals(value, otherValue)) + return true; + + if (value is null || otherValue is null) + return false; + + // Nobody can change a string or a copied value type behind our back: + if (value is string || value.GetType().IsValueType) + return value.Equals(otherValue); + + // + // The writer copies lists, sets, and dictionaries for every snapshot, so these never share a + // reference. Their items, though, are those of the assistant. Any other object may change + // without a new reference, e.g., a record holding a list, so only its reference tells: + // + if (value is IDictionary dictionary && otherValue is IDictionary otherDictionary) + return dictionary.Count == otherDictionary.Count && dictionary.Keys.Cast().All(key => otherDictionary.Contains(key) && AreSame(dictionary[key], otherDictionary[key])); + + if (value is IEnumerable items && otherValue is IEnumerable otherItems) + { + var itemList = items.Cast().ToList(); + var otherItemList = otherItems.Cast().ToList(); + return itemList.Count == otherItemList.Count && itemList.Zip(otherItemList).All(pair => AreSame(pair.First, pair.Second)); + } + + return false; + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AssistantSessions/IAssistantSessionSnapshotField.cs b/app/MindWork AI Studio/Tools/AssistantSessions/IAssistantSessionSnapshotField.cs index c64780a9b..590834911 100644 --- a/app/MindWork AI Studio/Tools/AssistantSessions/IAssistantSessionSnapshotField.cs +++ b/app/MindWork AI Studio/Tools/AssistantSessions/IAssistantSessionSnapshotField.cs @@ -17,4 +17,17 @@ public interface IAssistantSessionSnapshotField /// The typed value when reading succeeds. /// true when the captured value matches ; otherwise, false. bool TryRead(out T? value); + + /// + /// Tells whether another field provably holds the same value as this one. + /// + /// + /// The same means: the same captured type, and the same reference, an equal string or value of a + /// value type, or a collection whose items are the same by this rule in turn. Everything else counts + /// as changed. A field wrongly taken for changed is only restored once more, while a field wrongly + /// taken for the same would lose an update. + /// + /// The field to compare with. + /// true when both fields provably hold the same value; otherwise, false. + bool HasSameValueAs(IAssistantSessionSnapshotField other); } \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md index 76ecced29..787c37521 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md @@ -130,6 +130,7 @@ - Fixed photos taken with a phone appearing rotated in the Visual Briefing assistant when it optimizes images. - Fixed AI Studio sometimes discarding the answer of the AI when it chose your data sources, checked the passages it found, or checked an assistant plugin for security issues, because the AI wrote its answer in a slightly different form than expected. - Fixed assistants doing nothing at all when you started them without selecting a provider. They now ask you to select one, together with anything else that is still missing. +- Fixed an assistant undoing your changes every few seconds when you left it during a run and came back before it finished. Your changes now stay, and the result still appears when the assistant is done. In the Slide Assistant, this also ends short freezes while it plans. - Updated the code contributions on the supporters page, which now thank everyone who has contributed code to AI Studio so far. - Upgraded several libraries to improve security. - Upgraded to Rust v1.99.1 diff --git a/app/Tests/Tools/AssistantSessions/AssistantSessionSnapshotTests.cs b/app/Tests/Tools/AssistantSessions/AssistantSessionSnapshotTests.cs new file mode 100644 index 000000000..4b1a19b50 --- /dev/null +++ b/app/Tests/Tools/AssistantSessions/AssistantSessionSnapshotTests.cs @@ -0,0 +1,210 @@ +using AIStudio.Tools.AssistantSessions; + +namespace AIStudio.Tests.Tools.AssistantSessions; + +/// +/// Checks which state fields a component takes over from a run it did not start. +/// +/// +/// When the user leaves an assistant while it runs and comes back, the new component only shows the run, +/// while the old one keeps working out of sight. The form of the old one no longer changes, so the new +/// component must take over only what the run changed: otherwise, every report of the run undoes what +/// the user changed meanwhile. Wrongly taking a field for changed only restores it once more, while +/// wrongly taking it for the same loses an update, so whatever is not provably the same counts as changed. +/// +[TestFixture] +public sealed class AssistantSessionSnapshotTests +{ + private static readonly AssistantSessionStateKey TEXT_KEY = new("Text"); + private static readonly AssistantSessionStateKey NUMBER_KEY = new("Number"); + private static readonly AssistantSessionStateKey STATUS_KEY = new("Status"); + private static readonly AssistantSessionStateKey OPTIONAL_TEXT_KEY = new("OptionalText"); + private static readonly AssistantSessionStateKey> LINES_KEY = new("Lines"); + private static readonly AssistantSessionStateKey> NOTES_KEY = new("Notes"); + private static readonly AssistantSessionStateKey> COUNTS_KEY = new("Counts"); + private static readonly AssistantSessionStateKey NOTE_KEY = new("Note"); + private static readonly AssistantSessionStateKey NUMBER_AS_TEXT_KEY = new("Text"); + + private static readonly Guid SESSION_ID = Guid.NewGuid(); + + private sealed record Note(string Text); + + [Test] + public void WithoutAnEarlierSnapshotTheWholeStateIsTakenOver() + { + var snapshot = SnapshotOf(SESSION_ID, state => + { + state.Set(TEXT_KEY, "title"); + state.Set(NUMBER_KEY, 3); + }); + + var changed = snapshot.StateChangedSince(null); + + Assert.That(changed.Keys, Is.EquivalentTo(new[] { TEXT_KEY.Name, NUMBER_KEY.Name }), "A component which attaches for the first time has nothing yet, so it takes over everything."); + } + + [Test] + public void AfterAnotherRunTheWholeStateIsTakenOver() + { + var previous = SnapshotOf(Guid.NewGuid(), state => state.Set(TEXT_KEY, "title")); + var snapshot = SnapshotOf(SESSION_ID, state => state.Set(TEXT_KEY, "title")); + + var changed = snapshot.StateChangedSince(previous); + + Assert.That(changed.Keys, Is.EquivalentTo(new[] { TEXT_KEY.Name }), "What the component took over from another run says nothing about this one."); + } + + [Test] + public void UnchangedValuesAreLeftOut() + { + var previous = SnapshotOf(SESSION_ID, state => + { + state.Set(TEXT_KEY, "title"); + state.Set(NUMBER_KEY, 3); + state.Set(STATUS_KEY, AssistantSessionStatus.RUNNING); + }); + + var snapshot = SnapshotOf(SESSION_ID, state => + { + state.Set(TEXT_KEY, new string("title".ToCharArray())); + state.Set(NUMBER_KEY, 3); + state.Set(STATUS_KEY, AssistantSessionStatus.RUNNING); + }); + + var changed = snapshot.StateChangedSince(previous); + + Assert.That(changed, Is.Empty, "The run left these fields alone, so the user may have changed them, and the component must keep that."); + } + + [Test] + public void ChangedAndNewValuesAreTakenOver() + { + var previous = SnapshotOf(SESSION_ID, state => + { + state.Set(TEXT_KEY, "title"); + state.Set(NUMBER_KEY, 3); + state.Set(STATUS_KEY, AssistantSessionStatus.RUNNING); + }); + + var snapshot = SnapshotOf(SESSION_ID, state => + { + state.Set(TEXT_KEY, "title"); + state.Set(NUMBER_KEY, 4); + state.Set(STATUS_KEY, AssistantSessionStatus.COMPLETED); + state.Set(OPTIONAL_TEXT_KEY, "result"); + }); + + var changed = snapshot.StateChangedSince(previous); + + Assert.That(changed.Keys, Is.EquivalentTo(new[] { NUMBER_KEY.Name, STATUS_KEY.Name, OPTIONAL_TEXT_KEY.Name }), "Progress and results come from the run, so they must arrive."); + } + + [Test] + public void CopiedCollectionsWithTheSameItemsAreLeftOut() + { + var note = new Note("first"); + List lines = ["a", "b"]; + HashSet notes = [note]; + var counts = new Dictionary { ["a"] = 1, ["b"] = 2 }; + + var previous = SnapshotOf(SESSION_ID, state => + { + state.SetList(LINES_KEY, lines); + state.SetHashSet(NOTES_KEY, notes); + state.SetDictionary(COUNTS_KEY, counts); + }); + + var snapshot = SnapshotOf(SESSION_ID, state => + { + state.SetList(LINES_KEY, lines); + state.SetHashSet(NOTES_KEY, notes); + state.SetDictionary(COUNTS_KEY, counts); + }); + + var changed = snapshot.StateChangedSince(previous); + + Assert.That(changed, Is.Empty, "The writer copies collections for every snapshot, so a new copy with the same items is no change."); + } + + [Test] + public void CollectionsWithOtherItemsAreTakenOver() + { + var previous = SnapshotOf(SESSION_ID, state => + { + state.SetList(LINES_KEY, ["a", "b"]); + state.SetHashSet(NOTES_KEY, [new Note("first")]); + state.SetDictionary(COUNTS_KEY, new Dictionary { ["a"] = 1 }); + }); + + var snapshot = SnapshotOf(SESSION_ID, state => + { + state.SetList(LINES_KEY, ["a", "c"]); + state.SetHashSet(NOTES_KEY, [new Note("first"), new Note("second")]); + state.SetDictionary(COUNTS_KEY, new Dictionary { ["b"] = 1 }); + }); + + var changed = snapshot.StateChangedSince(previous); + + Assert.That(changed.Keys, Is.EquivalentTo(new[] { LINES_KEY.Name, NOTES_KEY.Name, COUNTS_KEY.Name })); + } + + [Test] + public void NullIsTheSameOnlyAsNull() + { + var previous = SnapshotOf(SESSION_ID, state => + { + state.Set(OPTIONAL_TEXT_KEY, null); + state.Set(TEXT_KEY, "title"); + }); + + var snapshot = SnapshotOf(SESSION_ID, state => + { + state.Set(OPTIONAL_TEXT_KEY, null); + state.Set(TEXT_KEY, null!); + }); + + var changed = snapshot.StateChangedSince(previous); + + Assert.That(changed.Keys, Is.EquivalentTo(new[] { TEXT_KEY.Name })); + } + + [Test] + public void AValueOfAnotherTypeIsTakenOver() + { + var previous = SnapshotOf(SESSION_ID, state => state.Set(TEXT_KEY, "3")); + var snapshot = SnapshotOf(SESSION_ID, state => state.Set(NUMBER_AS_TEXT_KEY, 3)); + + var changed = snapshot.StateChangedSince(previous); + + Assert.That(changed.Keys, Is.EquivalentTo(new[] { NUMBER_AS_TEXT_KEY.Name }), "A field captured with another type is another value, whatever it looks like."); + } + + [Test] + public void AnObjectIsTheSameOnlyAsItself() + { + var note = new Note("first"); + var previous = SnapshotOf(SESSION_ID, state => state.Set(NOTE_KEY, note)); + var sameNote = SnapshotOf(SESSION_ID, state => state.Set(NOTE_KEY, note)); + var equalNote = SnapshotOf(SESSION_ID, state => state.Set(NOTE_KEY, new Note(note.Text))); + + Assert.That(sameNote.StateChangedSince(previous), Is.Empty, "The run still holds the same object."); + Assert.That(equalNote.StateChangedSince(previous).Keys, Is.EquivalentTo(new[] { NOTE_KEY.Name }), "An object may change without a new reference, so only its reference proves that it is the same."); + } + + private static AssistantSessionSnapshot SnapshotOf(Guid sessionId, Action capture) + { + var state = new AssistantSessionStateWriter(); + capture(state); + + return new() + { + SessionId = sessionId, + Key = new(AIStudio.Tools.Components.SLIDE_BUILDER_ASSISTANT, "test"), + Title = "Test", + Status = AssistantSessionStatus.RUNNING, + StartedAt = DateTimeOffset.MinValue, + UpdatedAt = DateTimeOffset.MinValue, + State = state.ToDictionary(), + }; + } +} \ No newline at end of file