feat: attach calendar metadata to active meetings
PR and Push Build/Test / build-and-test (push) Successful in 15m2s

This commit is contained in:
2026-08-05 11:58:25 +02:00
parent aa42e8edda
commit 1a341f1eaa
11 changed files with 713 additions and 48 deletions
@@ -900,6 +900,185 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task AttachPromptedMetadataUpdatesTheActiveMeetingWithoutInterruptingRecording()
{
var audioSource = new ControlledAudioSource();
var transcriptStore = new InMemoryTranscriptStore();
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\active-metadata-meeting.md");
var artifactStore = new InMemoryMeetingArtifactStore();
var workflowEngine = new TransformingAttendeeWorkflowEngine(
"Ada Lovelace (Contoso)",
"Ada Lovelace");
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
transcriptStore,
noteStore,
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
meetingMetadataProvider: new CountingMeetingMetadataProvider(),
meetingWorkflowEngine: workflowEngine);
var started = await coordinator.StartAsync(CancellationToken.None);
noteStore.UpdateSavedNote(noteStore.SavedNote! with { UserNotes = "Keep this user note." });
var promptedMetadata = new MeetingMetadata(
"Selected architecture sync",
["Ada Lovelace (Contoso)"],
"Review the selected architecture",
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"));
var attached = await coordinator.AttachMetadataToCurrentMeetingAsync(
promptedMetadata,
CancellationToken.None);
Assert.True(started.IsRecording);
Assert.True(attached.IsRecording);
Assert.Equal(started.TranscriptPath, attached.TranscriptPath);
Assert.Equal("Selected architecture sync", noteStore.SavedNote?.Frontmatter.Title);
Assert.Equal(["Ada Lovelace"], noteStore.SavedNote?.Frontmatter.Attendees);
Assert.Equal("Keep this user note.", noteStore.SavedNote?.UserNotes);
Assert.Equal("Selected architecture sync", artifactStore.ContextMeetingNote?.Frontmatter.Title);
Assert.Equal("Review the selected architecture", artifactStore.Agenda);
Assert.Equal(
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
artifactStore.ScheduledEnd);
Assert.Equal(2, transcriptStore.MetadataUpdateCount);
Assert.Equal("Selected architecture sync", transcriptStore.MetadataMeetingNote?.Frontmatter.Title);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task AttachedPromptMetadataWinsOverAStandaloneLookupThatCompletesLater()
{
var audioSource = new ControlledAudioSource();
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\explicit-metadata-meeting.md");
var artifactStore = new InMemoryMeetingArtifactStore();
var metadataProvider = new BlockingMeetingMetadataProvider(new MeetingMetadata(
"Background calendar match",
["Grace"],
"Background agenda",
DateTimeOffset.Parse("2026-05-19T12:00:00+02:00")));
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);
var promptedMetadata = new MeetingMetadata(
"Explicit prompted appointment",
["Ada"],
"Explicit agenda",
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"));
await coordinator.StartAsync(CancellationToken.None);
await metadataProvider.WaitUntilRequestedAsync();
await coordinator.AttachMetadataToCurrentMeetingAsync(promptedMetadata, CancellationToken.None);
metadataProvider.Release();
await WaitUntilAsync(() => artifactStore.States.Contains(AssistantContextState.Transcribing));
Assert.Equal("Explicit prompted appointment", noteStore.SavedNote?.Frontmatter.Title);
Assert.Equal(["Ada"], noteStore.SavedNote?.Frontmatter.Attendees);
Assert.Equal("Explicit agenda", artifactStore.Agenda);
Assert.Equal(
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
artifactStore.ScheduledEnd);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task AttachPromptedMetadataDoesNothingAfterTheActiveRecordingStops()
{
var audioSource = new ControlledAudioSource();
var transcriptStore = new InMemoryTranscriptStore();
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\stopped-metadata-meeting.md");
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
transcriptStore,
noteStore,
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
meetingMetadataProvider: new CountingMeetingMetadataProvider());
await coordinator.StartAsync(CancellationToken.None);
var stopped = await coordinator.StopAsync(CancellationToken.None);
var metadataUpdatesBeforeAttach = transcriptStore.MetadataUpdateCount;
var titleBeforeAttach = noteStore.SavedNote?.Frontmatter.Title;
var result = await coordinator.AttachMetadataToCurrentMeetingAsync(
new MeetingMetadata(
"Stale prompted appointment",
["Ada"],
"Stale agenda",
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00")),
CancellationToken.None);
Assert.False(stopped.IsRecording);
Assert.False(result.IsRecording);
Assert.Equal(titleBeforeAttach, noteStore.SavedNote?.Frontmatter.Title);
Assert.Equal(metadataUpdatesBeforeAttach, transcriptStore.MetadataUpdateCount);
}
[Fact]
public async Task FailedPromptMetadataAttachmentDoesNotSuppressTheBackgroundLookup()
{
var audioSource = new ControlledAudioSource();
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\failed-attach-metadata-meeting.md");
var artifactStore = new InMemoryMeetingArtifactStore(failFirstMetadataUpdate: true);
var metadataProvider = new BlockingMeetingMetadataProvider(new MeetingMetadata(
"Background calendar fallback",
["Grace"],
"Background fallback agenda",
DateTimeOffset.Parse("2026-05-19T12:00:00+02:00")));
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);
await coordinator.StartAsync(CancellationToken.None);
await metadataProvider.WaitUntilRequestedAsync();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
coordinator.AttachMetadataToCurrentMeetingAsync(
new MeetingMetadata(
"Prompt attachment that fails",
["Ada"],
"Prompt agenda",
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00")),
CancellationToken.None));
metadataProvider.Release();
await WaitUntilAsync(() => artifactStore.States.Contains(AssistantContextState.Transcribing));
Assert.Equal("Background calendar fallback", noteStore.SavedNote?.Frontmatter.Title);
Assert.Equal(["Grace"], noteStore.SavedNote?.Frontmatter.Attendees);
Assert.Equal("Background fallback agenda", artifactStore.Agenda);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartTransformsMetadataAttendeesBeforeWritingNote()
{
@@ -2825,6 +3004,8 @@ public sealed class RecordingCoordinatorTests
public MeetingNote? MetadataMeetingNote { get; private set; }
public int MetadataUpdateCount { get; private set; }
public Task ReplaceLinesAsync(
TranscriptSession session,
IReadOnlyList<string> replacementLines,
@@ -2840,6 +3021,7 @@ public sealed class RecordingCoordinatorTests
MeetingNote meetingNote,
CancellationToken cancellationToken)
{
MetadataUpdateCount++;
MetadataMeetingNote = meetingNote;
return Task.CompletedTask;
}
@@ -3266,10 +3448,14 @@ public sealed class RecordingCoordinatorTests
private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore
{
private readonly bool createAssistantContextFile;
private bool failNextMetadataUpdate;
public InMemoryMeetingArtifactStore(bool createAssistantContextFile = false)
public InMemoryMeetingArtifactStore(
bool createAssistantContextFile = false,
bool failFirstMetadataUpdate = false)
{
this.createAssistantContextFile = createAssistantContextFile;
failNextMetadataUpdate = failFirstMetadataUpdate;
}
public MeetingSessionArtifacts? CreatedArtifacts { get; private set; }
@@ -3327,6 +3513,12 @@ public sealed class RecordingCoordinatorTests
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken)
{
if (failNextMetadataUpdate)
{
failNextMetadataUpdate = false;
throw new InvalidOperationException("Metadata artifact update failed.");
}
ContextMeetingNote = meetingNote;
Agenda = agenda;
ScheduledEnd = scheduledEnd;
@@ -3431,6 +3623,7 @@ public sealed class RecordingCoordinatorTests
private sealed class BlockingMeetingMetadataProvider : IMeetingMetadataProvider
{
private readonly MeetingMetadata metadata;
private readonly TaskCompletionSource requested = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously);
public BlockingMeetingMetadataProvider(MeetingMetadata metadata)
@@ -3443,10 +3636,16 @@ public sealed class RecordingCoordinatorTests
release.TrySetResult();
}
public Task WaitUntilRequestedAsync()
{
return requested.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public async Task<MeetingMetadata?> GetCurrentMeetingAsync(
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
requested.TrySetResult();
await release.Task.WaitAsync(cancellationToken);
return metadata;
}