using MeetingAssistant.MeetingNotes; using MeetingAssistant.Summary; using Microsoft.Extensions.Logging.Abstractions; namespace MeetingAssistant.Tests; public sealed class MeetingSummaryRetryRunnerTests { [Fact] public async Task TriggerStartsRetryInBackgroundAndTransitionsAssistantContextState() { var artifacts = new MeetingSessionArtifacts( "meeting.md", "transcript.md", "context.md", "summary.md"); var resolver = new FixedSummaryArtifactResolver(artifacts); var pipeline = new BlockingSummaryPipeline(); var artifactStore = new CapturingArtifactStore(); var runner = new MeetingSummaryRetryRunner( resolver, pipeline, artifactStore, NullLogger.Instance); var result = await runner.TriggerAsync( "summary.md", new MeetingAssistantOptions(), CancellationToken.None); Assert.NotNull(result); Assert.Equal("summary.md", result.SummaryPath); Assert.False(pipeline.Completed); await pipeline.WaitUntilStartedAsync(); Assert.Equal([AssistantContextState.Summarizing], artifactStore.States); pipeline.CompleteSuccessfully(); await artifactStore.WaitForStateCountAsync(2); Assert.Equal( [AssistantContextState.Summarizing, AssistantContextState.Finished], artifactStore.States); } [Fact] public async Task TriggerMarksAssistantContextErrorWhenRetryFails() { var artifacts = new MeetingSessionArtifacts( "meeting.md", "transcript.md", "context.md", "summary.md"); var resolver = new FixedSummaryArtifactResolver(artifacts); var pipeline = new BlockingSummaryPipeline(); var artifactStore = new CapturingArtifactStore(); var runner = new MeetingSummaryRetryRunner( resolver, pipeline, artifactStore, NullLogger.Instance); await runner.TriggerAsync( "summary.md", new MeetingAssistantOptions(), CancellationToken.None); await pipeline.WaitUntilStartedAsync(); pipeline.CompleteWithError(); await artifactStore.WaitForStateCountAsync(2); Assert.Equal( [AssistantContextState.Summarizing, AssistantContextState.Error], artifactStore.States); } private sealed class FixedSummaryArtifactResolver : IMeetingSummaryArtifactResolver { private readonly MeetingSessionArtifacts? artifacts; public FixedSummaryArtifactResolver(MeetingSessionArtifacts? artifacts) { this.artifacts = artifacts; } public Task ResolveBySummaryPathAsync( string summaryPath, CancellationToken cancellationToken) { return Task.FromResult(artifacts); } public Task ResolveBySummaryPathAsync( string summaryPath, MeetingAssistantOptions options, CancellationToken cancellationToken) { return Task.FromResult(artifacts); } } private sealed class BlockingSummaryPipeline : IMeetingSummaryPipeline { private readonly TaskCompletionSource started = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); public bool Completed { get; private set; } public async Task RunAsync( MeetingSessionArtifacts artifacts, CancellationToken cancellationToken) { started.TrySetResult(); var result = await completion.Task.WaitAsync(cancellationToken); Completed = true; return result; } public Task WaitUntilStartedAsync() { return started.Task.WaitAsync(TimeSpan.FromSeconds(5)); } public void CompleteSuccessfully() { completion.TrySetResult(new MeetingSummaryRunResult("summary.md", "ok")); } public void CompleteWithError() { completion.TrySetResult(new MeetingSummaryRunResult("summary.md", "failed", false, "failed")); } } private sealed class CapturingArtifactStore : IMeetingArtifactStore { private TaskCompletionSource stateChanged = new(TaskCreationOptions.RunContinuationsAsynchronously); public List States { get; } = []; public Task CreateAssistantContextAsync( MeetingSessionArtifacts artifacts, MeetingNote meetingNote, string agenda, DateTimeOffset? scheduledEnd, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task UpdateAssistantContextMetadataAsync( MeetingSessionArtifacts artifacts, MeetingNote meetingNote, string agenda, DateTimeOffset? scheduledEnd, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task UpdateAssistantContextMeetingAsync( MeetingSessionArtifacts artifacts, MeetingNote meetingNote, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task UpdateAssistantContextStateAsync( MeetingSessionArtifacts artifacts, AssistantContextState state, CancellationToken cancellationToken) { States.Add(state); var observed = stateChanged; observed.TrySetResult(); stateChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); return Task.CompletedTask; } public async Task WaitForStateCountAsync(int count) { var deadline = DateTimeOffset.UtcNow.AddSeconds(5); while (DateTimeOffset.UtcNow < deadline) { if (States.Count >= count) { return; } await stateChanged.Task.WaitAsync(TimeSpan.FromMilliseconds(100)); } throw new TimeoutException($"Expected {count} state changes."); } } }