Archive meeting workflow and screenshot changes

This commit is contained in:
2026-05-27 12:55:19 +02:00
parent 2422236ef7
commit 12832dde84
48 changed files with 3041 additions and 43 deletions
@@ -1,6 +1,7 @@
using MeetingAssistant.Recording;
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Speakers;
using MeetingAssistant.Screenshots;
using MeetingAssistant.Transcription;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary;
@@ -30,6 +31,36 @@ public sealed class RecordingCoordinatorTests
throw new TimeoutException("Condition was not met.");
}
private static async Task AppendTextWithRetryAsync(string path, string text)
{
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (true)
{
try
{
await File.AppendAllTextAsync(path, text);
return;
}
catch (IOException) when (DateTimeOffset.UtcNow < deadline)
{
await Task.Delay(25);
}
}
}
private static bool FileContainsText(string path, string text)
{
try
{
return File.Exists(path) &&
File.ReadAllText(path).Contains(text, StringComparison.Ordinal);
}
catch (IOException)
{
return false;
}
}
[Fact]
public async Task ToggleStartsStreamingTranscriptionAndSecondToggleStopsIt()
{
@@ -183,6 +214,47 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartSkipsOutlookMeetingAttendeesAboveConfiguredImportLimit()
{
var audioSource = new ControlledAudioSource();
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\metadata-meeting.md");
var artifactStore = new InMemoryMeetingArtifactStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
noteStore,
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions
{
Recording =
{
MaxMetadataAttendeeImportCount = 2
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
new FixedMeetingMetadataProvider(new MeetingMetadata(
"Presentation Review",
["Ada", "Grace", "Linus"],
"Large audience agenda",
DateTimeOffset.Parse("2026-05-19T14:00:00+02:00"))));
await coordinator.StartAsync(CancellationToken.None);
await WaitUntilAsync(() => artifactStore.Agenda == "Large audience agenda");
Assert.Equal("Presentation Review", noteStore.SavedNote?.Frontmatter.Title);
Assert.Empty(noteStore.SavedNote?.Frontmatter.Attendees ?? []);
Assert.Equal("Presentation Review", artifactStore.ContextMeetingNote?.Frontmatter.Title);
Assert.Equal("Large audience agenda", artifactStore.Agenda);
Assert.Equal(DateTimeOffset.Parse("2026-05-19T14:00:00+02:00"), artifactStore.ScheduledEnd);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartDoesNotWaitForSlowMeetingMetadataButAppliesItLater()
{
@@ -364,6 +436,94 @@ public sealed class RecordingCoordinatorTests
await store.WaitForTextAsync("final:2");
}
[Fact]
public async Task AbortStopsRecordingDeletesArtifactsAndSkipsSummary()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var options = new MeetingAssistantOptions
{
Vault = new VaultOptions
{
BaseFolder = root,
TranscriptsFolder = "Transcripts",
MeetingNotesFolder = "Notes",
AssistantContextFolder = "Context",
SummariesFolder = "Summaries"
},
Recording = new RecordingOptions
{
StopProcessingTimeout = TimeSpan.FromSeconds(5)
}
};
var audioSource = new ControlledAudioSource();
var summaryPipeline = new CapturingMeetingSummaryPipeline();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new VaultTranscriptStore(Options.Create(options), NullLogger<VaultTranscriptStore>.Instance),
new MarkdownMeetingNoteStore(Options.Create(options), NullLogger<MarkdownMeetingNoteStore>.Instance),
new CapturingMeetingNoteOpener(),
new MarkdownMeetingArtifactStore(NullLogger<MarkdownMeetingArtifactStore>.Instance),
new InMemoryRecordedAudioStore(),
summaryPipeline,
Options.Create(options),
NullLogger<MeetingRecordingCoordinator>.Instance);
var started = await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await WaitUntilAsync(() =>
FileContainsText(started.AssistantContextPath!, "state: transcribing"));
var attachmentPath = Path.Combine(
Path.GetDirectoryName(started.AssistantContextPath!)!,
"Attachments",
"20260527-093135-123-000010-cropped.png");
Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!);
await File.WriteAllTextAsync(attachmentPath, "image");
await AppendTextWithRetryAsync(
started.AssistantContextPath!,
Environment.NewLine + "![Cropped screenshot](Attachments/20260527-093135-123-000010-cropped.png)" + Environment.NewLine);
Directory.CreateDirectory(Path.GetDirectoryName(started.SummaryPath!)!);
await File.WriteAllTextAsync(started.SummaryPath!, "draft summary");
var aborted = await coordinator.AbortAsync(CancellationToken.None);
Assert.False(aborted.IsRecording);
Assert.Null(aborted.MeetingNotePath);
Assert.False(File.Exists(started.MeetingNotePath));
Assert.False(File.Exists(started.TranscriptPath));
Assert.False(File.Exists(started.AssistantContextPath));
Assert.False(File.Exists(started.SummaryPath));
Assert.False(File.Exists(attachmentPath));
Assert.Null(summaryPipeline.Artifacts);
await Task.Delay(250);
Assert.False(File.Exists(started.AssistantContextPath));
}
[Fact]
public async Task AbortDoesNothingWhenRecordingIsIdle()
{
var coordinator = new MeetingRecordingCoordinator(
new ControlledAudioSource(),
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance);
var status = await coordinator.AbortAsync(CancellationToken.None);
Assert.False(status.IsRecording);
Assert.Null(status.MeetingNotePath);
Assert.Null(status.TranscriptPath);
Assert.Null(status.AssistantContextPath);
Assert.Null(status.SummaryPath);
}
[Fact]
public async Task CaptureStartsEvenWhenTranscriptionProviderIsStillWarmingUp()
{
@@ -950,6 +1110,98 @@ public sealed class RecordingCoordinatorTests
artifactStore.States);
}
[Fact]
public async Task CaptureScreenshotUsesCurrentMeetingArtifactsAndStartTime()
{
var audioSource = new ControlledAudioSource();
var screenshotService = new CapturingMeetingScreenshotService();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(CreateOptionsWithoutFinalizer()),
NullLogger<MeetingRecordingCoordinator>.Instance,
screenshotService: screenshotService);
var started = await coordinator.StartAsync(CancellationToken.None);
var result = await coordinator.CaptureScreenshotAsync(CancellationToken.None);
Assert.Equal(started.AssistantContextPath, screenshotService.Artifacts?.AssistantContextPath);
Assert.NotNull(screenshotService.MeetingStartedAt);
Assert.Equal("memory-screenshot.png", result.ScreenshotPath);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task CaptureScreenshotFailsAfterRecordingStopHasStarted()
{
var audioSource = new ControlledAudioSource();
var screenshotService = new CapturingMeetingScreenshotService();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(CreateOptionsWithoutFinalizer()),
NullLogger<MeetingRecordingCoordinator>.Instance,
screenshotService: screenshotService);
await coordinator.StartAsync(CancellationToken.None);
var stop = coordinator.StopAsync(CancellationToken.None);
await Assert.ThrowsAsync<InvalidOperationException>(
() => coordinator.CaptureScreenshotAsync(CancellationToken.None));
audioSource.Complete();
await stop;
}
[Fact]
public async Task StopWaitsForPendingScreenshotOcrBeforeSummarizing()
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
var artifactStore = new InMemoryMeetingArtifactStore();
var summaryPipeline = new CapturingMeetingSummaryPipeline();
var screenshotService = new BlockingMeetingScreenshotService();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
summaryPipeline,
Options.Create(CreateOptionsWithoutFinalizer()),
NullLogger<MeetingRecordingCoordinator>.Instance,
screenshotService: screenshotService);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WaitUntilCapturedAsync();
var stop = coordinator.StopAsync(CancellationToken.None);
await screenshotService.WaitUntilOcrWaitStartedAsync();
Assert.DoesNotContain(AssistantContextState.Summarizing, artifactStore.States);
Assert.Null(summaryPipeline.Artifacts);
screenshotService.ReleaseOcrWait();
await stop;
Assert.Contains(AssistantContextState.Summarizing, artifactStore.States);
Assert.NotNull(summaryPipeline.Artifacts);
}
[Fact]
public async Task StopMarksAssistantContextErrorWhenSummaryFails()
{
@@ -1396,6 +1648,93 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class CapturingMeetingScreenshotService : IMeetingScreenshotService
{
public MeetingSessionArtifacts? Artifacts { get; private set; }
public DateTimeOffset? MeetingStartedAt { get; private set; }
public Task<MeetingScreenshotCaptureResult> CaptureAsync(
MeetingSessionArtifacts artifacts,
DateTimeOffset? meetingStartedAt,
DateTimeOffset capturedAt,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
Artifacts = artifacts;
MeetingStartedAt = meetingStartedAt;
return Task.FromResult(new MeetingScreenshotCaptureResult(
"memory-screenshot.png",
TimeSpan.FromSeconds(1),
OcrStarted: false));
}
public Task WaitForPendingOcrAsync(
MeetingSessionArtifacts artifacts,
TimeSpan timeout,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task CancelPendingOcrAsync(
MeetingSessionArtifacts artifacts,
TimeSpan timeout,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
private sealed class BlockingMeetingScreenshotService : IMeetingScreenshotService
{
private readonly TaskCompletionSource waitStarted =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource releaseWait =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<MeetingScreenshotCaptureResult> CaptureAsync(
MeetingSessionArtifacts artifacts,
DateTimeOffset? meetingStartedAt,
DateTimeOffset capturedAt,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return Task.FromResult(new MeetingScreenshotCaptureResult(
"memory-screenshot.png",
TimeSpan.Zero,
OcrStarted: true));
}
public async Task WaitForPendingOcrAsync(
MeetingSessionArtifacts artifacts,
TimeSpan timeout,
CancellationToken cancellationToken)
{
waitStarted.TrySetResult();
await releaseWait.Task.WaitAsync(cancellationToken);
}
public Task CancelPendingOcrAsync(
MeetingSessionArtifacts artifacts,
TimeSpan timeout,
CancellationToken cancellationToken)
{
releaseWait.TrySetResult();
return Task.CompletedTask;
}
public Task WaitUntilOcrWaitStartedAsync()
{
return waitStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public void ReleaseOcrWait()
{
releaseWait.TrySetResult();
}
}
private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore
{
public MeetingSessionArtifacts? CreatedArtifacts { get; private set; }
@@ -2140,6 +2479,11 @@ public sealed class RecordingCoordinatorTests
{
return chunks.Writer.WriteAsync(chunk, cancellationToken);
}
public void Complete()
{
chunks.Writer.TryComplete();
}
}
private sealed class CapturedChunkThenCancelAudioSource : IMeetingAudioSource