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
@@ -123,6 +123,49 @@ public sealed class CalendarRecordingPromptSchedulerTests
Assert.Equal(["stop", "start"], harness.Recorder.Commands);
}
[Fact]
public async Task ActiveRecordingPromptAttachesTheSelectedAppointmentMetadataWithoutRestarting()
{
var harness = CreateHarness(isRecording: true, autoAcceptPrompts: false);
var firstMetadata = new MeetingMetadata(
"First planning",
["Ada"],
"First agenda",
DateTimeOffset.Parse("2026-06-03T10:30:00+00:00"));
var secondMetadata = new MeetingMetadata(
"Second planning",
["Grace"],
"Second agenda",
DateTimeOffset.Parse("2026-06-03T10:30:00+00:00"));
var firstMeeting = CreateMeeting(
harness.Clock,
id: "teams-first",
subject: "First planning",
metadata: firstMetadata);
var secondMeeting = CreateMeeting(
harness.Clock,
id: "teams-second",
subject: "Second planning",
metadata: secondMetadata);
harness.Provider.Meetings = [firstMeeting, secondMeeting];
await harness.Scheduler.SyncOnceAsync(CancellationToken.None);
harness.Clock.Now = firstMeeting.Start;
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
await harness.PromptService.RespondAsync(
secondMeeting,
MeetingStartPromptResponse.AttachMetadataToCurrentMeeting);
Assert.All(
harness.PromptService.PromptRequests,
request => Assert.True(request.CanAttachToCurrentMeeting));
Assert.Equal(["attach-metadata"], harness.Recorder.Commands);
Assert.Same(secondMetadata, harness.Recorder.AttachedMetadata.Single());
Assert.Equal(0, harness.Recorder.StopCount);
Assert.Equal(0, harness.Recorder.StartCount);
Assert.True(harness.Recorder.CurrentStatus.IsRecording);
}
[Fact]
public async Task CanceledCachedMeetingDoesNotPromptRecording()
{
@@ -302,14 +345,17 @@ public sealed class CalendarRecordingPromptSchedulerTests
this.autoAccept = autoAccept;
}
public List<CalendarMeeting> PromptedMeetings { get; } = [];
public List<MeetingStartPromptRequest> PromptRequests { get; } = [];
public IReadOnlyList<CalendarMeeting> PromptedMeetings =>
PromptRequests.Select(request => request.Meeting).ToList();
public async Task ShowPromptAsync(
MeetingStartPromptRequest request,
Func<MeetingStartPromptResponse, CancellationToken, Task> handleResponseAsync,
CancellationToken cancellationToken)
{
PromptedMeetings.Add(request.Meeting);
PromptRequests.Add(request);
pendingPrompts.Add(new PendingPrompt(request.Meeting, handleResponseAsync));
if (autoAccept)
{
@@ -349,6 +395,8 @@ public sealed class CalendarRecordingPromptSchedulerTests
public List<MeetingMetadata?> StartMetadata { get; } = [];
public List<MeetingMetadata> AttachedMetadata { get; } = [];
public Task<RecordingStatus> StartAsync(CancellationToken cancellationToken)
{
return StartRecordingAsync(null);
@@ -380,6 +428,15 @@ public sealed class CalendarRecordingPromptSchedulerTests
return Task.FromResult(CurrentStatus);
}
public Task<RecordingStatus> AttachMetadataToCurrentMeetingAsync(
MeetingMetadata metadata,
CancellationToken cancellationToken)
{
Commands.Add("attach-metadata");
AttachedMetadata.Add(metadata);
return Task.FromResult(CurrentStatus);
}
private static RecordingStatus Status(bool isRecording)
{
return new RecordingStatus(
@@ -40,7 +40,7 @@ public sealed class OutlookMeetingCandidateSelectorTests
{
var now = new DateTime(2026, 5, 20, 10, 0, 0);
var endingOverlap = new Candidate(now.AddMinutes(-25), now.AddMinutes(2));
var upcoming = new Candidate(now.AddMinutes(5), now.AddMinutes(35));
var upcoming = new Candidate(now.AddMinutes(1), now.AddMinutes(31));
var selected = OutlookMeetingCandidateSelector.Select(
[endingOverlap, upcoming],
@@ -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;
}