Archive completed meeting assistant changes
PR and Push Build/Test / build-and-test (push) Successful in 9m19s

This commit is contained in:
2026-06-26 13:15:47 +02:00
parent 7d16b0cad7
commit aecef30627
72 changed files with 2914 additions and 406 deletions
@@ -784,6 +784,88 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartWithPromptedMetadataBypassesLookupAndRunsWorkflowWithMetadata()
{
var audioSource = new ControlledAudioSource();
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\prompted-metadata-meeting.md");
var artifactStore = new InMemoryMeetingArtifactStore();
var metadataProvider = new CountingMeetingMetadataProvider();
var workflowEngine = new MetadataObservingWorkflowEngine(() => noteStore.SavedNote);
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
noteStore,
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
meetingMetadataProvider: metadataProvider,
meetingWorkflowEngine: workflowEngine);
var promptedMetadata = new MeetingMetadata(
"Prompted Architecture Sync",
["Ada <ada@example.com>"],
"Prompted agenda",
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"));
await coordinator.StartFromPromptAsync(promptedMetadata, CancellationToken.None);
await WaitUntilAsync(() => workflowEngine.ObservedEvents.Any(entry =>
entry.Type == MeetingWorkflowEventType.StateTransition &&
entry.Title == "Prompted Architecture Sync"));
Assert.Equal(0, metadataProvider.CallCount);
Assert.Equal("Prompted Architecture Sync", noteStore.SavedNote?.Frontmatter.Title);
Assert.Equal(["Ada <ada@example.com>"], noteStore.SavedNote?.Frontmatter.Attendees);
Assert.Equal("Prompted agenda", artifactStore.Agenda);
Assert.Equal(DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"), artifactStore.ScheduledEnd);
Assert.Collection(
workflowEngine.ObservedEvents.Where(entry =>
entry.Type is MeetingWorkflowEventType.Created or MeetingWorkflowEventType.StateTransition),
created =>
{
Assert.Equal(MeetingWorkflowEventType.Created, created.Type);
Assert.StartsWith("Meeting ", created.Title, StringComparison.Ordinal);
},
transition =>
{
Assert.Equal(MeetingWorkflowEventType.StateTransition, transition.Type);
Assert.Equal("Prompted Architecture Sync", transition.Title);
Assert.Equal(AssistantContextState.CollectingMetadata, transition.FromState);
Assert.Equal(AssistantContextState.Transcribing, transition.ToState);
});
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartFromPromptWithoutMetadataBypassesLookup()
{
var audioSource = new ControlledAudioSource();
var metadataProvider = new CountingMeetingMetadataProvider();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
meetingMetadataProvider: metadataProvider);
await coordinator.StartFromPromptAsync(null, CancellationToken.None);
await Task.Delay(100);
Assert.Equal(0, metadataProvider.CallCount);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartCanonicalizesOutlookMeetingAttendeesBeforeWritingNote()
{
@@ -1353,7 +1435,9 @@ public sealed class RecordingCoordinatorTests
Assert.Equal([null, "english"], pipelineFactory.ProfileNames);
Assert.Contains(transcriptStore.Segments, segment =>
segment.Speaker == "System" &&
segment.Text.Contains("Transcription profile changed to english", StringComparison.Ordinal));
segment.Text.Contains(
"Transcription profile changed to english. Speaker recognition and identities reset.",
StringComparison.Ordinal));
Assert.Null(summaryPipeline.Artifacts);
await WaitUntilAsync(() => metadataProvider.CallCount == 1);
@@ -2121,6 +2205,44 @@ public sealed class RecordingCoordinatorTests
Assert.NotNull(summaryPipeline.Artifacts);
}
[Fact]
public async Task StopProcessesMeetingNoteImageEmbedsBeforeSummarizing()
{
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,
meetingNoteImageOcrService: screenshotService);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WaitUntilCapturedAsync();
var stop = coordinator.StopAsync(CancellationToken.None);
await screenshotService.WaitUntilMeetingNoteImageProcessingStartedAsync();
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()
{
@@ -2866,6 +2988,46 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class MetadataObservingWorkflowEngine : IMeetingWorkflowEngine
{
private readonly Func<MeetingNote?> getMeetingNote;
public MetadataObservingWorkflowEngine(Func<MeetingNote?> getMeetingNote)
{
this.getMeetingNote = getMeetingNote;
}
public List<ObservedWorkflowEvent> ObservedEvents { get; } = [];
public Task RunAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var note = getMeetingNote();
ObservedEvents.Add(new ObservedWorkflowEvent(
workflowEvent.Type,
note?.Frontmatter.Title,
workflowEvent.FromState,
workflowEvent.ToState));
return Task.CompletedTask;
}
public Task<string> TransformTranscriptLineAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return Task.FromResult(workflowEvent.TranscriptLineText ?? "");
}
public sealed record ObservedWorkflowEvent(
MeetingWorkflowEventType Type,
string? Title,
AssistantContextState? FromState,
AssistantContextState? ToState);
}
private sealed class FailingFirstTranscriptLineWorkflowEngine : IMeetingWorkflowEngine
{
public Task RunAsync(
@@ -3003,12 +3165,14 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class BlockingMeetingScreenshotService : IMeetingScreenshotService
private sealed class BlockingMeetingScreenshotService : IMeetingScreenshotService, IMeetingNoteImageOcrService
{
private readonly TaskCompletionSource waitStarted =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource releaseWait =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource meetingNoteImageProcessingStarted =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<MeetingScreenshotCaptureResult> CaptureAsync(
MeetingSessionArtifacts artifacts,
@@ -3041,6 +3205,20 @@ public sealed class RecordingCoordinatorTests
return Task.CompletedTask;
}
public Task<MeetingNoteImageOcrQueueResult> ProcessMeetingNoteImageEmbedsAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
meetingNoteImageProcessingStarted.TrySetResult();
return Task.FromResult(new MeetingNoteImageOcrQueueResult(0));
}
public Task WaitUntilMeetingNoteImageProcessingStartedAsync()
{
return meetingNoteImageProcessingStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public Task WaitUntilOcrWaitStartedAsync()
{
return waitStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
@@ -3683,9 +3861,9 @@ public sealed class RecordingCoordinatorTests
return Task.FromResult<IReadOnlyList<OfflineTranscriptionBacklogItem>>(Items.ToList());
}
public Task CompleteAsync(string id, CancellationToken cancellationToken)
public Task CompleteAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken)
{
Items.RemoveAll(item => string.Equals(item.Id, id, StringComparison.Ordinal));
Items.RemoveAll(existing => string.Equals(existing.Id, item.Id, StringComparison.Ordinal));
return Task.CompletedTask;
}
}