Author SHA1 Message Date
codex 1a341f1eaa feat: attach calendar metadata to active meetings
PR and Push Build/Test / build-and-test (push) Successful in 15m2s
2026-08-05 11:58:25 +02:00
11 changed files with 713 additions and 48 deletions
@@ -123,6 +123,49 @@ public sealed class CalendarRecordingPromptSchedulerTests
Assert.Equal(["stop", "start"], harness.Recorder.Commands); 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] [Fact]
public async Task CanceledCachedMeetingDoesNotPromptRecording() public async Task CanceledCachedMeetingDoesNotPromptRecording()
{ {
@@ -302,14 +345,17 @@ public sealed class CalendarRecordingPromptSchedulerTests
this.autoAccept = autoAccept; 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( public async Task ShowPromptAsync(
MeetingStartPromptRequest request, MeetingStartPromptRequest request,
Func<MeetingStartPromptResponse, CancellationToken, Task> handleResponseAsync, Func<MeetingStartPromptResponse, CancellationToken, Task> handleResponseAsync,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
PromptedMeetings.Add(request.Meeting); PromptRequests.Add(request);
pendingPrompts.Add(new PendingPrompt(request.Meeting, handleResponseAsync)); pendingPrompts.Add(new PendingPrompt(request.Meeting, handleResponseAsync));
if (autoAccept) if (autoAccept)
{ {
@@ -349,6 +395,8 @@ public sealed class CalendarRecordingPromptSchedulerTests
public List<MeetingMetadata?> StartMetadata { get; } = []; public List<MeetingMetadata?> StartMetadata { get; } = [];
public List<MeetingMetadata> AttachedMetadata { get; } = [];
public Task<RecordingStatus> StartAsync(CancellationToken cancellationToken) public Task<RecordingStatus> StartAsync(CancellationToken cancellationToken)
{ {
return StartRecordingAsync(null); return StartRecordingAsync(null);
@@ -380,6 +428,15 @@ public sealed class CalendarRecordingPromptSchedulerTests
return Task.FromResult(CurrentStatus); 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) private static RecordingStatus Status(bool isRecording)
{ {
return new RecordingStatus( return new RecordingStatus(
@@ -40,7 +40,7 @@ public sealed class OutlookMeetingCandidateSelectorTests
{ {
var now = new DateTime(2026, 5, 20, 10, 0, 0); var now = new DateTime(2026, 5, 20, 10, 0, 0);
var endingOverlap = new Candidate(now.AddMinutes(-25), now.AddMinutes(2)); 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( var selected = OutlookMeetingCandidateSelector.Select(
[endingOverlap, upcoming], [endingOverlap, upcoming],
@@ -900,6 +900,185 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None); 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] [Fact]
public async Task StartTransformsMetadataAttendeesBeforeWritingNote() public async Task StartTransformsMetadataAttendeesBeforeWritingNote()
{ {
@@ -2825,6 +3004,8 @@ public sealed class RecordingCoordinatorTests
public MeetingNote? MetadataMeetingNote { get; private set; } public MeetingNote? MetadataMeetingNote { get; private set; }
public int MetadataUpdateCount { get; private set; }
public Task ReplaceLinesAsync( public Task ReplaceLinesAsync(
TranscriptSession session, TranscriptSession session,
IReadOnlyList<string> replacementLines, IReadOnlyList<string> replacementLines,
@@ -2840,6 +3021,7 @@ public sealed class RecordingCoordinatorTests
MeetingNote meetingNote, MeetingNote meetingNote,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
MetadataUpdateCount++;
MetadataMeetingNote = meetingNote; MetadataMeetingNote = meetingNote;
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -3266,10 +3448,14 @@ public sealed class RecordingCoordinatorTests
private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore
{ {
private readonly bool createAssistantContextFile; private readonly bool createAssistantContextFile;
private bool failNextMetadataUpdate;
public InMemoryMeetingArtifactStore(bool createAssistantContextFile = false) public InMemoryMeetingArtifactStore(
bool createAssistantContextFile = false,
bool failFirstMetadataUpdate = false)
{ {
this.createAssistantContextFile = createAssistantContextFile; this.createAssistantContextFile = createAssistantContextFile;
failNextMetadataUpdate = failFirstMetadataUpdate;
} }
public MeetingSessionArtifacts? CreatedArtifacts { get; private set; } public MeetingSessionArtifacts? CreatedArtifacts { get; private set; }
@@ -3327,6 +3513,12 @@ public sealed class RecordingCoordinatorTests
DateTimeOffset? scheduledEnd, DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (failNextMetadataUpdate)
{
failNextMetadataUpdate = false;
throw new InvalidOperationException("Metadata artifact update failed.");
}
ContextMeetingNote = meetingNote; ContextMeetingNote = meetingNote;
Agenda = agenda; Agenda = agenda;
ScheduledEnd = scheduledEnd; ScheduledEnd = scheduledEnd;
@@ -3431,6 +3623,7 @@ public sealed class RecordingCoordinatorTests
private sealed class BlockingMeetingMetadataProvider : IMeetingMetadataProvider private sealed class BlockingMeetingMetadataProvider : IMeetingMetadataProvider
{ {
private readonly MeetingMetadata metadata; private readonly MeetingMetadata metadata;
private readonly TaskCompletionSource requested = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously);
public BlockingMeetingMetadataProvider(MeetingMetadata metadata) public BlockingMeetingMetadataProvider(MeetingMetadata metadata)
@@ -3443,10 +3636,16 @@ public sealed class RecordingCoordinatorTests
release.TrySetResult(); release.TrySetResult();
} }
public Task WaitUntilRequestedAsync()
{
return requested.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public async Task<MeetingMetadata?> GetCurrentMeetingAsync( public async Task<MeetingMetadata?> GetCurrentMeetingAsync(
DateTimeOffset startedAt, DateTimeOffset startedAt,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
requested.TrySetResult();
await release.Task.WaitAsync(cancellationToken); await release.Task.WaitAsync(cancellationToken);
return metadata; return metadata;
} }
@@ -98,7 +98,9 @@ public sealed class CalendarRecordingPromptScheduler : BackgroundService
meeting.Subject, meeting.Subject,
meeting.Start); meeting.Start);
await promptService.ShowPromptAsync( await promptService.ShowPromptAsync(
new MeetingStartPromptRequest(meeting), new MeetingStartPromptRequest(
meeting,
recordingController.CurrentStatus.IsRecording && meeting.Metadata is not null),
(response, token) => HandlePromptResponseAsync(meeting, response, token), (response, token) => HandlePromptResponseAsync(meeting, response, token),
cancellationToken); cancellationToken);
} }
@@ -139,6 +141,18 @@ public sealed class CalendarRecordingPromptScheduler : BackgroundService
MeetingStartPromptResponse response, MeetingStartPromptResponse response,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (response == MeetingStartPromptResponse.AttachMetadataToCurrentMeeting)
{
if (meeting.Metadata is not null)
{
await recordingController.AttachMetadataToCurrentMeetingAsync(
meeting.Metadata,
cancellationToken);
}
return;
}
if (response != MeetingStartPromptResponse.Record) if (response != MeetingStartPromptResponse.Record)
{ {
return; return;
@@ -244,12 +258,15 @@ public interface IMeetingStartPromptService
CancellationToken cancellationToken); CancellationToken cancellationToken);
} }
public sealed record MeetingStartPromptRequest(CalendarMeeting Meeting); public sealed record MeetingStartPromptRequest(
CalendarMeeting Meeting,
bool CanAttachToCurrentMeeting = false);
public enum MeetingStartPromptResponse public enum MeetingStartPromptResponse
{ {
Record, Record,
Skip Skip,
AttachMetadataToCurrentMeeting
} }
public interface IMeetingPromptRecordingController public interface IMeetingPromptRecordingController
@@ -262,6 +279,10 @@ public interface IMeetingPromptRecordingController
MeetingMetadata? metadata, MeetingMetadata? metadata,
CancellationToken cancellationToken); CancellationToken cancellationToken);
Task<RecordingStatus> AttachMetadataToCurrentMeetingAsync(
MeetingMetadata metadata,
CancellationToken cancellationToken);
Task<RecordingStatus> StopAsync(CancellationToken cancellationToken); Task<RecordingStatus> StopAsync(CancellationToken cancellationToken);
} }
@@ -288,6 +309,13 @@ public sealed class MeetingPromptRecordingController : IMeetingPromptRecordingCo
return coordinator.StartFromPromptAsync(metadata, cancellationToken); return coordinator.StartFromPromptAsync(metadata, cancellationToken);
} }
public Task<RecordingStatus> AttachMetadataToCurrentMeetingAsync(
MeetingMetadata metadata,
CancellationToken cancellationToken)
{
return coordinator.AttachMetadataToCurrentMeetingAsync(metadata, cancellationToken);
}
public Task<RecordingStatus> StopAsync(CancellationToken cancellationToken) public Task<RecordingStatus> StopAsync(CancellationToken cancellationToken)
{ {
return coordinator.StopAsync(cancellationToken); return coordinator.StopAsync(cancellationToken);
@@ -118,21 +118,10 @@ public sealed class WindowsMeetingStartPromptService : IMeetingStartPromptServic
string promptId, string promptId,
MeetingStartPromptRequest request) MeetingStartPromptRequest request)
{ {
var yesButton = new ToastButton() var yesButton = BuildResponseButton("Yes", promptId, "record");
.SetContent("Yes") var noButton = BuildResponseButton("No", promptId, "skip");
.AddArgument("source", NotificationSource)
.AddArgument("promptId", promptId)
.AddArgument("response", "record")
.SetBackgroundActivation();
var noButton = new ToastButton() var notification = new ToastContentBuilder()
.SetContent("No")
.AddArgument("source", NotificationSource)
.AddArgument("promptId", promptId)
.AddArgument("response", "skip")
.SetBackgroundActivation();
return new ToastContentBuilder()
.AddArgument("source", NotificationSource) .AddArgument("source", NotificationSource)
.AddArgument("promptId", promptId) .AddArgument("promptId", promptId)
.SetToastScenario(ToastScenario.Reminder) .SetToastScenario(ToastScenario.Reminder)
@@ -141,6 +130,30 @@ public sealed class WindowsMeetingStartPromptService : IMeetingStartPromptServic
.AddText($"{request.Meeting.Subject} starts at {request.Meeting.Start.LocalDateTime:t}.") .AddText($"{request.Meeting.Subject} starts at {request.Meeting.Start.LocalDateTime:t}.")
.AddButton(yesButton) .AddButton(yesButton)
.AddButton(noButton); .AddButton(noButton);
if (request.CanAttachToCurrentMeeting)
{
notification.AddButton(
BuildResponseButton(
"Add metadata to current meeting",
promptId,
"attach-metadata"));
}
return notification;
}
private static ToastButton BuildResponseButton(
string content,
string promptId,
string response)
{
return new ToastButton()
.SetContent(content)
.AddArgument("source", NotificationSource)
.AddArgument("promptId", promptId)
.AddArgument("response", response)
.SetBackgroundActivation();
} }
private void OnNotificationInvoked(ToastNotificationActivatedEventArgsCompat args) private void OnNotificationInvoked(ToastNotificationActivatedEventArgsCompat args)
@@ -166,10 +179,14 @@ public sealed class WindowsMeetingStartPromptService : IMeetingStartPromptServic
return; return;
} }
var response = TryGetArgument(arguments, "response", out var responseValue) && var response = TryGetArgument(arguments, "response", out var responseValue)
string.Equals(responseValue, "record", StringComparison.OrdinalIgnoreCase) ? responseValue.ToLowerInvariant() switch
? MeetingStartPromptResponse.Record {
: MeetingStartPromptResponse.Skip; "record" => MeetingStartPromptResponse.Record,
"attach-metadata" => MeetingStartPromptResponse.AttachMetadataToCurrentMeeting,
_ => MeetingStartPromptResponse.Skip
}
: MeetingStartPromptResponse.Skip;
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
@@ -155,6 +155,44 @@ public sealed class MeetingRecordingCoordinator
return await StartAsync(null, metadata, suppressMetadataLookup: true, cancellationToken); return await StartAsync(null, metadata, suppressMetadataLookup: true, cancellationToken);
} }
public async Task<RecordingStatus> AttachMetadataToCurrentMeetingAsync(
MeetingMetadata metadata,
CancellationToken cancellationToken)
{
await gate.WaitAsync(cancellationToken);
try
{
var run = currentRun;
if (run is null || run.IsCaptureStopping)
{
return CurrentStatus;
}
var meetingNote = await ApplyAndPersistMeetingMetadataAsync(
run.Artifacts,
await meetingNoteStore.ReadAsync(run.MeetingNotePath, cancellationToken),
metadata,
run.Options,
cancellationToken);
currentMeetingNote = meetingNote;
await transcriptStore.UpdateMetadataAsync(
run.Session,
run.Artifacts,
meetingNote,
cancellationToken);
run.MarkPromptMetadataAttached();
logger.LogInformation(
"Attached prompted calendar metadata to active meeting {MeetingNotePath}",
run.MeetingNotePath);
return CurrentStatus;
}
finally
{
gate.Release();
}
}
public async Task<RecordingStatus> StartAsync( public async Task<RecordingStatus> StartAsync(
string? launchProfileName, string? launchProfileName,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -213,22 +251,12 @@ public sealed class MeetingRecordingCoordinator
currentMeetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken); currentMeetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
if (suppliedMetadata is not null) if (suppliedMetadata is not null)
{ {
await ApplyMeetingMetadataAsync( currentMeetingNote = await ApplyAndPersistMeetingMetadataAsync(
currentArtifacts, currentArtifacts,
currentMeetingNote, currentMeetingNote,
suppliedMetadata, suppliedMetadata,
runOptions, runOptions,
cancellationToken); cancellationToken);
currentMeetingNote = await meetingNoteStore.SaveAsync(
currentMeetingNote,
runOptions,
cancellationToken);
await meetingArtifactStore.UpdateAssistantContextMetadataAsync(
currentArtifacts,
currentMeetingNote,
suppliedMetadata.Agenda,
suppliedMetadata.ScheduledEnd,
cancellationToken);
} }
await meetingArtifactStore.UpdateAssistantContextMeetingAsync( await meetingArtifactStore.UpdateAssistantContextMeetingAsync(
@@ -1021,7 +1049,7 @@ public sealed class MeetingRecordingCoordinator
return; return;
} }
if (run.IsAborted) if (run.IsAborted || run.HasAttachedPromptMetadata)
{ {
return; return;
} }
@@ -1029,25 +1057,21 @@ public sealed class MeetingRecordingCoordinator
await gate.WaitAsync(CancellationToken.None); await gate.WaitAsync(CancellationToken.None);
try try
{ {
if (run.IsAborted) if (run.IsAborted || run.HasAttachedPromptMetadata)
{ {
return; return;
} }
var meetingNote = await meetingNoteStore.ReadAsync(run.MeetingNotePath, CancellationToken.None); var meetingNote = await ApplyAndPersistMeetingMetadataAsync(
await ApplyMeetingMetadataAsync(run.Artifacts, meetingNote, metadata, run.Options, CancellationToken.None); run.Artifacts,
meetingNote = await meetingNoteStore.SaveAsync(meetingNote, run.Options, CancellationToken.None); await meetingNoteStore.ReadAsync(run.MeetingNotePath, CancellationToken.None),
metadata,
run.Options,
CancellationToken.None);
if (currentMeetingNote?.Path == meetingNote.Path) if (currentMeetingNote?.Path == meetingNote.Path)
{ {
currentMeetingNote = meetingNote; currentMeetingNote = meetingNote;
} }
await meetingArtifactStore.UpdateAssistantContextMetadataAsync(
run.Artifacts,
meetingNote,
metadata.Agenda,
metadata.ScheduledEnd,
CancellationToken.None);
logger.LogInformation( logger.LogInformation(
"Applied Outlook meeting metadata to {MeetingNotePath}", "Applied Outlook meeting metadata to {MeetingNotePath}",
run.MeetingNotePath); run.MeetingNotePath);
@@ -1119,6 +1143,32 @@ public sealed class MeetingRecordingCoordinator
} }
} }
private async Task<MeetingNote> ApplyAndPersistMeetingMetadataAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
MeetingMetadata metadata,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
await ApplyMeetingMetadataAsync(
artifacts,
meetingNote,
metadata,
options,
cancellationToken);
var savedMeetingNote = await meetingNoteStore.SaveAsync(
meetingNote,
options,
cancellationToken);
await meetingArtifactStore.UpdateAssistantContextMetadataAsync(
artifacts,
savedMeetingNote,
metadata.Agenda,
metadata.ScheduledEnd,
cancellationToken);
return savedMeetingNote;
}
private async Task<List<string>> TransformAttendeesAsync( private async Task<List<string>> TransformAttendeesAsync(
MeetingSessionArtifacts artifacts, MeetingSessionArtifacts artifacts,
IReadOnlyList<string> attendees, IReadOnlyList<string> attendees,
@@ -1908,6 +1958,8 @@ public sealed class MeetingRecordingCoordinator
public bool HasSwitchedProfile { get; private set; } public bool HasSwitchedProfile { get; private set; }
public bool HasAttachedPromptMetadata { get; private set; }
public AssistantContextState ContextState { get; private set; } = AssistantContextState.CollectingMetadata; public AssistantContextState ContextState { get; private set; } = AssistantContextState.CollectingMetadata;
public DateTimeOffset? InferredEndTime public DateTimeOffset? InferredEndTime
@@ -1926,6 +1978,11 @@ public sealed class MeetingRecordingCoordinator
CaptureCancellationSource.Cancel(); CaptureCancellationSource.Cancel();
} }
public void MarkPromptMetadataAttached()
{
HasAttachedPromptMetadata = true;
}
public void Abort() public void Abort()
{ {
IsAborted = true; IsAborted = true;
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-05
@@ -0,0 +1,69 @@
## Context
Standalone Outlook enrichment already selects a single suitable Teams appointment and already contains a fixed five-minute upcoming-start window, but that behavior is not represented in the accepted specification. The selector intentionally returns no metadata when the suitable candidates are ambiguous. Calendar notifications avoid that ambiguity because each notification carries one cached appointment and its metadata.
The prompt scheduler currently offers `Record` and `Skip`. Choosing `Record` while a meeting is active stops that meeting and starts a new run with the prompted metadata. Metadata application is currently private to recording startup/background lookup, so the scheduler has no safe way to enrich the active run in place.
## Goals / Non-Goals
**Goals:**
- Specify and preserve the existing five-minute pre-start Outlook metadata grace period.
- Preserve conservative standalone selection when more than one appointment is suitable.
- Offer an appointment-specific metadata action only when a recording is active and the prompt carries metadata.
- Apply that metadata atomically to the active run without interrupting capture or transcription.
- Prevent a slower standalone Outlook lookup from overwriting explicitly attached prompt metadata.
- Reuse existing attendee canonicalization, attendee-added workflow transformations, attendee import limits, and artifact rendering.
**Non-Goals:**
- Automatically choose among concurrent appointments.
- Change the existing `Yes` behavior that finishes the current recording and starts the prompted appointment as a new recording.
- Add a new workflow trigger or replay an already-completed lifecycle state transition.
- Guarantee an exact action-button row layout that the native Windows toast renderer does not expose to the application.
## Decisions
### Keep the five-minute grace period in the shared candidate selector
The current selector already treats exactly one appointment starting within five minutes as eligible when there is no suitable overlap. The change will codify this behavior in OpenSpec and retain its behavior test. Candidate selection remains conservative: multiple suitable overlaps or multiple upcoming candidates return no selection.
This keeps manual enrichment independent of the calendar prompt cache. Using the prompt cache for all metadata lookup was considered, but it would couple ordinary recording startup to an optional hosted feature and its sync freshness.
### Put active-recording capability on the prompt request
The scheduler will snapshot whether the prompt can attach metadata when it calls the prompt service. The Windows adapter will add a third `Add metadata to current meeting` background action after the existing `Yes` and `No` actions only when that flag is true. The response enum will carry a distinct attach result, so the callback still identifies the exact cached appointment.
The native toast API controls final action layout and does not provide a reliable per-button full-row placement contract. Adding the action after the two short actions gives the renderer the best available ordering while keeping the label explicit.
### Add one coordinator operation for explicit active-run metadata
`IMeetingPromptRecordingController` will expose an attach operation backed by `MeetingRecordingCoordinator`. Under the coordinator gate, it will require a currently capturing run, mark that run as explicitly assigned, re-read the latest meeting note, apply the shared metadata rules, save the note, and refresh assistant-context and transcript metadata. Capture and transcription continue unchanged.
Centralizing the mutation in the coordinator keeps run ownership, artifact paths, profile options, and serialization under the same synchronization boundary. Direct file mutation from the scheduler was rejected because it could race recording lifecycle writes and would bypass attendee normalization and workflow transformations.
### Explicit prompt metadata wins over background lookup
Each recording run will track whether appointment metadata was explicitly assigned. The background Outlook task will check this flag before and after acquiring the coordinator gate. If explicit prompt metadata has already been attached, the background result is discarded. If the background update wins the gate first, the later explicit action overwrites it, so the user's appointment choice remains authoritative.
### Do not replay lifecycle transitions
Attendee-added transformations run as part of the shared metadata application path. The meeting has already transitioned from `collecting metadata` to `transcribing`, so the attach action will not fabricate or replay that state transition. Existing live speaker matching already observes changes to meeting-note attendees.
## Risks / Trade-offs
[Native toast may not render the third action as a full-width row] -> Keep it as the final, clearly labelled action and let Windows choose the physical layout.
[The recording stops before the user activates the notification] -> Recheck active capture under the coordinator gate and leave artifacts unchanged if there is no current recording.
[Metadata attachment replaces title and attendee metadata] -> Reuse the established prompted-start semantics so the selected appointment becomes authoritative while preserving the note body and other user-authored content.
[A delayed background lookup races the explicit action] -> Record explicit assignment on the run and make the explicit appointment win regardless of completion order.
## Migration Plan
No data or configuration migration is required. The notification gains one conditional action, and existing `Yes`/`No` activation arguments remain valid. Rollback removes the action and coordinator method without changing stored meeting artifacts.
## Open Questions
None.
@@ -0,0 +1,28 @@
## Why
Manual recordings started shortly before an appointment currently miss Outlook metadata, while concurrent appointments make automatic lookup ambiguous. Once an appointment-specific recording notification appears during an active recording, the user also has no way to attach that exact appointment's metadata without stopping the current meeting.
## What Changes
- Treat a Teams appointment that starts within five minutes after a manual recording begins as current for Outlook metadata enrichment.
- Keep standalone metadata lookup conservative when multiple in-progress appointments match, or when multiple appointments fall in the grace window and none is already in progress.
- When a calendar recording prompt is shown during an active recording, add an `Add metadata to current meeting` action below the existing affirmative and negative actions when the Windows notification layout permits it.
- Apply the prompted appointment's title, eligible attendees, agenda, and scheduled end to the active meeting without stopping or starting a recording.
- Reuse the normal attendee transformation and artifact-update behavior when prompted metadata is attached to an active meeting.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `meeting-session`: Extend Outlook enrichment with a five-minute pre-start grace period and allow appointment-specific prompt metadata to be attached to an active meeting.
## Impact
- Outlook current-meeting candidate selection and its Windows provider.
- Calendar prompt scheduling, response contracts, and Windows toast layout/activation handling.
- The active recording controller/coordinator metadata update surface.
- Meeting-note, assistant-context, transcript metadata, and workflow behavior tests.
@@ -0,0 +1,184 @@
## MODIFIED Requirements
### Requirement: Windows Outlook enrichment is optional
Meeting Assistant SHALL gate Outlook Classic COM enrichment behind the Windows compilation target.
For standalone metadata lookup, Meeting Assistant SHALL first consider Teams appointments that are in progress with at least five minutes remaining. If exactly one such appointment exists, Meeting Assistant SHALL select it even when another appointment starts within five minutes.
If no suitable in-progress appointment exists, Meeting Assistant SHALL consider Teams appointments that start within five minutes after recording starts and SHALL select one only when exactly one such upcoming appointment exists.
When the Windows build starts a meeting and Outlook Classic yields one appointment through that selection order, Meeting Assistant SHALL copy the appointment title to the meeting note and copy the appointment agenda and scheduled end time to the assistant-context frontmatter.
When more than one appointment exists in the applicable in-progress or upcoming selection group, Meeting Assistant SHALL leave standalone metadata unselected.
Meeting Assistant SHALL exclude canceled Outlook appointments from current Teams appointment metadata lookup.
Meeting Assistant SHALL copy the appointment attendees to the meeting note only when the raw appointment attendee count is less than or equal to the configured `Recording:MaxMetadataAttendeeImportCount`. The default maximum SHALL be 30 attendees.
The agenda SHALL be extracted from the appointment body content before the Teams join separator or Teams join text.
#### Scenario: Current Teams appointment enriches meeting artifacts
- **WHEN** a Windows build starts a meeting while Outlook Classic selects exactly one Teams appointment through the standalone selection order
- **THEN** Meeting Assistant uses the appointment subject as the meeting title
- **AND** writes the appointment attendees into meeting note frontmatter when the raw attendee count is within the configured import limit
- **AND** writes the appointment agenda into assistant-context frontmatter
- **AND** writes the appointment end time as `scheduled_end` into assistant-context frontmatter
#### Scenario: Meeting starting within five minutes enriches an early recording
- **GIVEN** no suitable Teams appointment is already in progress
- **AND** exactly one Teams appointment starts one minute after the recording start time
- **WHEN** a Windows build starts the meeting recording
- **THEN** Meeting Assistant selects that upcoming appointment's metadata
#### Scenario: In-progress meeting takes priority over an upcoming meeting
- **GIVEN** exactly one Teams appointment is in progress with at least five minutes remaining
- **AND** another Teams appointment starts within five minutes after the recording start time
- **WHEN** a Windows build starts the meeting recording
- **THEN** Meeting Assistant selects the in-progress appointment's metadata
#### Scenario: Canceled appointment is ignored during metadata lookup
- **GIVEN** Outlook Classic exposes one canceled suitable Teams appointment
- **AND** Outlook Classic exposes one active suitable Teams appointment at the same time
- **WHEN** a Windows build starts a meeting
- **THEN** Meeting Assistant selects the active appointment metadata
#### Scenario: Oversized attendee list is not imported
- **GIVEN** the configured metadata attendee import limit is 30
- **WHEN** a Windows build starts a meeting while Outlook Classic exposes exactly one suitable Teams appointment with 31 attendees
- **THEN** Meeting Assistant uses the appointment subject as the meeting title
- **AND** does not write the appointment attendees into meeting note frontmatter
- **AND** writes the appointment agenda into assistant-context frontmatter
- **AND** writes the appointment end time as `scheduled_end` into assistant-context frontmatter
#### Scenario: Outlook is unavailable or metadata lookup is ambiguous
- **WHEN** Outlook Classic is unavailable or more than one Teams appointment exists in the applicable in-progress or upcoming selection group
- **THEN** Meeting Assistant starts the recording with the default generated meeting title and empty agenda
- **AND** omits `scheduled_end` from assistant-context frontmatter
### Requirement: Outlook Teams meetings can prompt recording start
Meeting Assistant SHALL enable scheduled Outlook Classic calendar checks for recording-start prompts by default.
When scheduled recording prompts are enabled on Windows, Meeting Assistant SHALL periodically read the user's Outlook Classic calendar appointments for the current local day through COM into an in-memory cache.
Meeting Assistant SHALL default the Outlook calendar sync interval to 30 minutes when scheduled recording prompts are enabled.
Meeting Assistant SHALL schedule recording-start prompts from the cached calendar appointments rather than querying Outlook for each prompt.
Meeting Assistant SHALL consider Teams appointments from Outlook calendar data as initial prompt candidates. The detection MAY be extended later for other meeting providers.
Meeting Assistant SHALL exclude canceled Outlook appointments from recording-start prompt candidates.
When a Teams appointment reaches its scheduled start window, Meeting Assistant SHALL show a native Windows app notification asking whether to record the meeting, with affirmative and negative actions.
When a recording is active and the cached appointment has metadata, the recording-start notification SHALL additionally offer an `Add metadata to current meeting` action after the affirmative and negative actions. The native Windows notification renderer MAY choose the physical button layout.
On Windows, the recording-start notification SHALL request reminder-style toast behavior and remain actionable for 5 minutes.
Meeting Assistant SHALL prompt at most once per calendar appointment during a local day, regardless of whether the user accepts, declines, ignores, or attaches metadata from the notification.
If the user accepts the recording prompt while no recording is active, Meeting Assistant SHALL start a new recording normally.
If the user accepts the recording prompt while another recording is active, Meeting Assistant SHALL stop the active recording normally and then start the prompted meeting recording.
When stopping an active recording for an accepted prompt, Meeting Assistant SHALL use the normal stop path so empty or too-short recordings are removed according to existing settings and other completed recordings continue normal transcription, speaker recognition, and summary processing.
When the user accepts a recording prompt, Meeting Assistant SHALL start the recording with the accepted cached appointment's metadata and SHALL NOT perform a separate current-meeting metadata lookup for that prompted start.
Prompted-start metadata SHALL include the accepted appointment title, attendees, agenda, and scheduled end when those values are available from the cached appointment.
Prompted starts SHALL run the normal meeting workflow `created` rules before applying the accepted appointment metadata.
After `created` rules run, prompted starts SHALL apply the accepted appointment metadata and then run the normal `collecting metadata` to `transcribing` state-transition workflow rules with that metadata available in the meeting note.
If the user chooses `Add metadata to current meeting` while that recording is still active, Meeting Assistant SHALL apply the exact cached appointment's title, eligible attendees, agenda, and scheduled end to the active meeting without stopping or starting recording.
Attaching prompted metadata SHALL use the normal attendee canonicalization, attendee import limit, and `attendee_added` workflow transformations, SHALL preserve the meeting note body, and SHALL refresh meeting metadata in the meeting note, assistant context, and transcript artifacts.
Explicitly attached prompted metadata SHALL take precedence over any standalone Outlook metadata lookup still running for that recording.
If no recording is active when the attach action is handled, Meeting Assistant SHALL leave meeting artifacts and recording state unchanged.
#### Scenario: Teams meeting start prompts the user
- **GIVEN** scheduled Outlook recording prompts are enabled
- **AND** Meeting Assistant has synced Outlook Classic Teams appointments for today
- **WHEN** the appointment reaches its scheduled start window
- **THEN** Meeting Assistant shows a native Windows app notification asking whether to record the meeting
- **AND** the notification remains actionable for 5 minutes
- **AND** marks that appointment as prompted for the day
#### Scenario: Active recording prompt offers metadata attachment
- **GIVEN** scheduled Outlook recording prompts are enabled
- **AND** a meeting recording is active
- **AND** the due cached appointment has metadata
- **WHEN** Meeting Assistant shows the appointment's recording-start notification
- **THEN** the notification includes `Yes`, `No`, and `Add metadata to current meeting` actions
- **AND** orders the metadata action after `Yes` and `No`
#### Scenario: Idle prompt omits metadata attachment
- **GIVEN** scheduled Outlook recording prompts are enabled
- **AND** no meeting recording is active
- **WHEN** Meeting Assistant shows an appointment's recording-start notification
- **THEN** the notification includes the existing affirmative and negative actions
- **AND** does not include `Add metadata to current meeting`
#### Scenario: Canceled Teams meeting does not prompt recording
- **GIVEN** scheduled Outlook recording prompts are enabled
- **AND** Meeting Assistant has synced a canceled Outlook Classic Teams appointment for today
- **WHEN** the canceled appointment reaches its scheduled start window
- **THEN** Meeting Assistant does not show a recording prompt for that appointment
#### Scenario: Back-to-back cached Teams meetings prompt without another Outlook sync
- **GIVEN** scheduled Outlook recording prompts are enabled
- **AND** Meeting Assistant has synced two Teams appointments for today that start ten minutes apart
- **WHEN** each appointment reaches its scheduled start window
- **THEN** Meeting Assistant shows a recording prompt for each appointment
- **AND** does not require another Outlook calendar sync between the prompts
#### Scenario: User accepts prompt while idle
- **GIVEN** scheduled Outlook recording prompts are enabled
- **AND** no meeting recording is active
- **WHEN** the user accepts a Teams meeting recording prompt
- **THEN** Meeting Assistant starts recording normally
#### Scenario: User accepts prompt while already recording
- **GIVEN** scheduled Outlook recording prompts are enabled
- **AND** a meeting recording is active
- **WHEN** the user accepts a Teams meeting recording prompt
- **THEN** Meeting Assistant stops the active recording normally
- **AND** starts a new recording normally after the stop request
#### Scenario: Accepted prompt supplies meeting metadata
- **GIVEN** scheduled Outlook recording prompts are enabled
- **AND** Meeting Assistant has cached two current Teams appointments with different metadata
- **WHEN** the user accepts the recording prompt for one appointment
- **THEN** Meeting Assistant starts the recording with the accepted appointment's metadata
- **AND** does not perform a standalone current-meeting metadata lookup for that recording
- **AND** runs `created` workflow rules before writing the cached appointment metadata
- **AND** runs `collecting metadata` to `transcribing` workflow rules after writing the cached appointment metadata
#### Scenario: Prompted metadata is attached to the active meeting
- **GIVEN** a meeting recording is active without calendar metadata
- **AND** two concurrent appointment notifications carry different cached metadata
- **WHEN** the user chooses `Add metadata to current meeting` on one notification
- **THEN** Meeting Assistant applies only that notification's appointment metadata to the active meeting
- **AND** keeps the current recording and transcription running
- **AND** does not start a new recording
#### Scenario: Explicit attachment wins over delayed lookup
- **GIVEN** standalone Outlook metadata lookup is still running for an active recording
- **WHEN** the user attaches metadata from an appointment notification
- **AND** the standalone lookup completes later with different metadata
- **THEN** the prompted appointment metadata remains on the active meeting artifacts
#### Scenario: Attach action becomes stale
- **GIVEN** a recording-start notification offered `Add metadata to current meeting`
- **AND** the active recording stops before the action is handled
- **WHEN** the user activates the metadata action
- **THEN** Meeting Assistant does not change completed meeting artifacts
- **AND** does not start a recording
#### Scenario: Prompt is disabled
- **GIVEN** scheduled Outlook recording prompts are disabled
- **WHEN** a Teams appointment reaches its scheduled start
- **THEN** Meeting Assistant does not query Outlook for recording prompt candidates
- **AND** does not show a recording prompt
@@ -0,0 +1,24 @@
## 1. Calendar Selection Contract
- [x] 1.1 Verify the standalone Outlook selector covers a meeting started one minute early and remains ambiguous for concurrent upcoming meetings.
## 2. Prompt Attach Action
- [x] 2.1 Add a failing scheduler behavior test that an active-recording prompt exposes metadata attachment and routes the exact appointment metadata without stop/start.
- [x] 2.2 Implement the prompt request/response and recording-controller attach contract to pass the scheduler behavior test.
- [x] 2.3 Add the conditional `Add metadata to current meeting` Windows toast action and activation mapping while preserving Yes/No behavior.
## 3. Active Meeting Metadata
- [x] 3.1 Add a failing coordinator behavior test for attaching title, eligible transformed attendees, agenda, and scheduled end to active meeting, assistant-context, and transcript artifacts without interrupting recording.
- [x] 3.2 Implement synchronized active-run metadata attachment through the shared metadata application path.
- [x] 3.3 Add a failing race behavior test proving explicit prompted metadata wins over a delayed standalone lookup, then implement the run-level explicit-assignment guard.
- [x] 3.4 Add a stale-action behavior test proving attachment is a no-op after capture stops.
- [x] 3.5 Add a failing attachment behavior test proving a persistence failure does not suppress the fallback Outlook lookup, then mark metadata explicit only after successful persistence.
## 4. Verification
- [x] 4.1 Run focused calendar and recording coordinator tests, then the full test suite and Windows-target build.
- [x] 4.2 Run `openspec validate attach-calendar-metadata-to-active-meeting --strict` and verify the change task list is complete.
- [x] 4.3 Perform the required DRY, SOLID, and KISS refactoring passes with tests after any changes.
- [x] 4.4 Verify the notification and active-recording behavior through the safest available operational surface without interrupting live meeting work.