Public Access
feat: attach calendar metadata to active meetings
PR and Push Build/Test / build-and-test (push) Successful in 15m2s
PR and Push Build/Test / build-and-test (push) Successful in 15m2s
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user