using MeetingAssistant.MacOs; using MeetingAssistant.Workflow; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace MeetingAssistant.Tests; public sealed class MacOsWorkflowRulesEditorSessionTests { [Fact] public async Task ApprovedWorkflowStartsOnceInOwnSessionWithoutReplacingDefaultConversation() { var pipeline = new ReplyPipeline(); using var lifetime = new TestApplicationLifetime(); var sessions = CreateSessions(pipeline, lifetime); Assert.True(sessions.TryGetSession(null, out var normal)); await normal.SendAsync("My existing conversation", CancellationToken.None); const string prompt = "Given Alpha in the title,\nwhen metadata arrives, then associate project Alpha."; sessions.OpenWithPrompt(prompt); var window = Assert.Single(sessions.GetAgentWindows()); Assert.EndsWith($"/diagnostics/settings-and-logs?session={window.Id}", window.Url); Assert.True(sessions.TryGetSession(window.Id, out var requested)); var snapshot = await WaitForCompletionAsync(requested); Assert.Equal( [new MacOsWorkflowRulesEditorMessage("user", prompt), new MacOsWorkflowRulesEditorMessage("agent", "Agent response.")], snapshot.Messages); for (var index = 0; index < 3; index++) { Assert.Equal(snapshot.Messages, requested.GetSnapshot().Messages); Assert.False(requested.GetSnapshot().IsThinking); Assert.Single(sessions.GetAgentWindows()); } Assert.Equal( [new MacOsWorkflowRulesEditorMessage("user", "My existing conversation"), new MacOsWorkflowRulesEditorMessage("agent", "Agent response.")], normal.GetSnapshot().Messages); Assert.NotSame(normal, requested); } [Fact] public async Task ClosingAResponseConnectionKeepsAcceptedChatWorkingAndSnapshotsRemainReadable() { var pipeline = new BlockingPipeline(); using var lifetime = new TestApplicationLifetime(); var sessions = CreateSessions(pipeline, lifetime); Assert.True(sessions.TryGetSession(null, out var session)); using var responseConnection = new CancellationTokenSource(); var response = session.SendAsync("Continue the approved change", responseConnection.Token); await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); var pending = session.GetSnapshot(); Assert.True(pending.IsThinking); Assert.Equal([new MacOsWorkflowRulesEditorMessage("user", "Continue the approved change")], pending.Messages); responseConnection.Cancel(); await Assert.ThrowsAnyAsync(() => response); Assert.True(session.GetSnapshot().IsThinking); pipeline.Release.TrySetResult(); var completed = await WaitForCompletionAsync(session); Assert.False(completed.IsThinking); Assert.Equal("Agent response.", completed.Messages.Last().Content); Assert.Single(pending.Messages); Assert.True(pending.IsThinking); } [Fact] public async Task ApprovedWorkflowWindowOpensWithoutWaitingForItsAgentResponse() { var pipeline = new BlockingPipeline(); using var lifetime = new TestApplicationLifetime(); var sessions = CreateSessions(pipeline, lifetime); IWorkflowRulesEditorWindowService editor = new MacOsWorkflowRulesEditorWindowService( Options.Create(new MeetingAssistantOptions()), () => sessions); try { await Task.Run(() => editor.ShowWithPrompt("Apply the approved workflow scenario.")) .WaitAsync(TimeSpan.FromSeconds(2)); await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); var window = Assert.Single(sessions.GetAgentWindows()); Assert.True(sessions.TryGetSession(window.Id, out var session)); Assert.True(session.GetSnapshot().IsThinking); } finally { pipeline.Release.TrySetResult(); } } [Fact] public void ConstructingTheWindowServiceDoesNotStartOrResolveAnAgentConversation() { using var lifetime = new TestApplicationLifetime(); var sessions = CreateSessions(new ReplyPipeline(), lifetime); var resolutions = 0; IWorkflowRulesEditorWindowService editor = new MacOsWorkflowRulesEditorWindowService( Options.Create(new MeetingAssistantOptions()), () => { resolutions++; return sessions; }); Assert.Equal(0, resolutions); Assert.Empty(sessions.GetAgentWindows()); editor.ShowWithPrompt("An explicitly approved scenario."); Assert.Equal(1, resolutions); Assert.Single(sessions.GetAgentWindows()); } [Fact] public async Task TwoApprovedWorkflowsAndTheirFollowUpsRemainIndependent() { using var lifetime = new TestApplicationLifetime(); var sessions = CreateSessions(new ReplyPipeline(), lifetime); sessions.OpenWithPrompt("First approved scenario."); sessions.OpenWithPrompt("Second approved scenario."); var windows = sessions.GetAgentWindows(); Assert.Equal(2, windows.Count); Assert.Equal(2, windows.Select(window => window.Id).Distinct().Count()); var conversations = new List(); foreach (var window in windows) { Assert.True(sessions.TryGetSession(window.Id, out var conversation)); conversations.Add(conversation); } var snapshots = await Task.WhenAll(conversations.Select(WaitForCompletionAsync)); Assert.Equal( ["First approved scenario.", "Second approved scenario."], snapshots.Select(snapshot => snapshot.Messages[0].Content).Order()); var updated = await conversations[0].SendAsync("A follow-up in just this conversation.", CancellationToken.None); Assert.Equal(4, updated.Messages.Count); Assert.Equal(2, conversations[1].GetSnapshot().Messages.Count); Assert.True(sessions.TryGetSession(null, out var normal)); Assert.Empty(normal.GetSnapshot().Messages); } [Fact] public async Task ApplicationShutdownCancelsBackgroundConversationAndPreventsNewWindows() { var pipeline = new BlockingPipeline(); using var lifetime = new TestApplicationLifetime(); var sessions = CreateSessions(pipeline, lifetime); sessions.OpenWithPrompt("Apply the approved scenario."); await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); var window = Assert.Single(sessions.GetAgentWindows()); Assert.True(sessions.TryGetSession(window.Id, out var conversation)); lifetime.StopApplication(); using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(2)); while (conversation.GetSnapshot().IsThinking) await Task.Delay(10, deadline.Token); Assert.Empty(sessions.GetAgentWindows()); Assert.Single(conversation.GetSnapshot().Messages); Assert.ThrowsAny(() => sessions.OpenWithPrompt("A stale approval.")); } private static MacOsWorkflowRulesEditorSessions CreateSessions( IWorkflowRulesEditorChatPipeline pipeline, IHostApplicationLifetime lifetime) => new( pipeline, Options.Create(new MeetingAssistantOptions()), lifetime, NullLogger.Instance); private static async Task WaitForCompletionAsync( MacOsWorkflowRulesEditorSession session) { using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(2)); while (true) { var snapshot = session.GetSnapshot(); if (snapshot.Messages.Count >= 2 && !snapshot.IsThinking) return snapshot; await Task.Delay(10, deadline.Token); } } private sealed class ReplyPipeline : IWorkflowRulesEditorChatPipeline { public Task SendAsync( IReadOnlyList conversation, string userMessage, CancellationToken cancellationToken, Action? activityChanged = null) => Task.FromResult(new WorkflowRulesEditorChatResult("Agent response.")); } private sealed class BlockingPipeline : IWorkflowRulesEditorChatPipeline { public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public async Task SendAsync( IReadOnlyList conversation, string userMessage, CancellationToken cancellationToken, Action? activityChanged = null) { Started.TrySetResult(); await Release.Task.WaitAsync(cancellationToken); return new WorkflowRulesEditorChatResult("Agent response."); } } private sealed class TestApplicationLifetime : IHostApplicationLifetime, IDisposable { private readonly CancellationTokenSource stopping = new(); public CancellationToken ApplicationStarted => CancellationToken.None; public CancellationToken ApplicationStopping => stopping.Token; public CancellationToken ApplicationStopped => stopping.Token; public void StopApplication() => stopping.Cancel(); public void Dispose() { stopping.Cancel(); stopping.Dispose(); } } }