Public Access
Add inactivity safeguard and speaker diagnostics
PR and Push Build/Test / build-and-test (push) Failing after 8m31s
PR and Push Build/Test / build-and-test (push) Failing after 8m31s
This commit is contained in:
@@ -179,6 +179,212 @@ public sealed class RecordingCoordinatorTests
|
||||
Assert.Equal(started.SummaryPath, artifactCleaner.DeletedArtifacts?.SummaryPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InactivitySafeguardStopsNormallyWhenPromptIsAcceptedAndUsesTranscriptEndTime()
|
||||
{
|
||||
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-06-02T10:00:00+02:00"));
|
||||
var promptService = new CapturingMeetingInactivityPromptService(MeetingInactivityPromptResponse.Stop);
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
var summaryPipeline = new CapturingMeetingSummaryPipeline();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FixedSegmentStreamingTranscriptionProvider(
|
||||
new TranscriptionSegment(
|
||||
TimeSpan.FromMinutes(3),
|
||||
TimeSpan.FromMinutes(4),
|
||||
"Guest-01",
|
||||
"The latest transcript text before silence."))),
|
||||
transcriptStore,
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
summaryPipeline,
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording =
|
||||
{
|
||||
InactivitySafeguard =
|
||||
{
|
||||
FirstPromptAfter = TimeSpan.FromSeconds(2),
|
||||
ReminderPromptAfter = [],
|
||||
AutoStopAfter = TimeSpan.FromMinutes(30),
|
||||
InferredEndPadding = TimeSpan.FromMinutes(1),
|
||||
CheckInterval = TimeSpan.FromSeconds(1)
|
||||
}
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
inactivityPromptService: promptService,
|
||||
inactivityClock: clock);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment =>
|
||||
segment.Text.Contains("latest transcript text", StringComparison.Ordinal)));
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(2));
|
||||
await promptService.WaitForPromptAsync();
|
||||
await WaitUntilAsync(() => !coordinator.CurrentStatus.IsRecording);
|
||||
|
||||
Assert.Single(promptService.Requests);
|
||||
Assert.False(coordinator.CurrentStatus.IsRecording);
|
||||
Assert.True(summaryPipeline.WasRun);
|
||||
Assert.Equal(
|
||||
DateTimeOffset.Parse("2026-06-02T10:05:00+02:00"),
|
||||
noteStore.SavedNote?.Frontmatter.EndTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InactivitySafeguardAutoStopsNormallyAndUsesMeetingStartWhenNoTranscriptArrives()
|
||||
{
|
||||
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-06-02T11:00:00+02:00"));
|
||||
var promptService = new CapturingMeetingInactivityPromptService();
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
var summaryPipeline = new CapturingMeetingSummaryPipeline();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
summaryPipeline,
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording =
|
||||
{
|
||||
InactivitySafeguard =
|
||||
{
|
||||
FirstPromptAfter = TimeSpan.Zero,
|
||||
ReminderPromptAfter = [],
|
||||
AutoStopAfter = TimeSpan.FromSeconds(5),
|
||||
InferredEndPadding = TimeSpan.FromMinutes(1),
|
||||
CheckInterval = TimeSpan.FromSeconds(1)
|
||||
}
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
inactivityPromptService: promptService,
|
||||
inactivityClock: clock);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(5));
|
||||
await WaitUntilAsync(() => !coordinator.CurrentStatus.IsRecording);
|
||||
|
||||
Assert.Empty(promptService.Requests);
|
||||
Assert.False(coordinator.CurrentStatus.IsRecording);
|
||||
Assert.True(summaryPipeline.WasRun);
|
||||
Assert.Equal(
|
||||
DateTimeOffset.Parse("2026-06-02T11:01:00+02:00"),
|
||||
noteStore.SavedNote?.Frontmatter.EndTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InactivitySafeguardContinuesMonitoringWhenPromptIsIgnored()
|
||||
{
|
||||
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-06-02T11:30:00+02:00"));
|
||||
var promptService = new IgnoringMeetingInactivityPromptService();
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var summaryPipeline = new CapturingMeetingSummaryPipeline();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
summaryPipeline,
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording =
|
||||
{
|
||||
InactivitySafeguard =
|
||||
{
|
||||
FirstPromptAfter = TimeSpan.FromSeconds(2),
|
||||
ReminderPromptAfter = [],
|
||||
AutoStopAfter = TimeSpan.FromSeconds(5),
|
||||
CheckInterval = TimeSpan.FromSeconds(1)
|
||||
}
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
inactivityPromptService: promptService,
|
||||
inactivityClock: clock);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
clock.Advance(TimeSpan.FromSeconds(2));
|
||||
await promptService.WaitForPromptAsync();
|
||||
await WaitUntilAsync(() => clock.PendingDelayCount > 0);
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(3));
|
||||
await WaitUntilAsync(() => !coordinator.CurrentStatus.IsRecording);
|
||||
|
||||
Assert.Single(promptService.Requests);
|
||||
Assert.True(summaryPipeline.WasRun);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InactivitySafeguardResetsPromptScheduleWhenNewTranscriptTextArrives()
|
||||
{
|
||||
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-06-02T12:00:00+02:00"));
|
||||
var promptService = new CapturingMeetingInactivityPromptService(
|
||||
MeetingInactivityPromptResponse.Continue,
|
||||
MeetingInactivityPromptResponse.Continue);
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording =
|
||||
{
|
||||
InactivitySafeguard =
|
||||
{
|
||||
FirstPromptAfter = TimeSpan.FromSeconds(2),
|
||||
ReminderPromptAfter = [TimeSpan.FromSeconds(5)],
|
||||
AutoStopAfter = TimeSpan.FromMinutes(30),
|
||||
CheckInterval = TimeSpan.FromSeconds(1)
|
||||
}
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
inactivityPromptService: promptService,
|
||||
inactivityClock: clock);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment =>
|
||||
segment.Text.Contains("chunk:2", StringComparison.Ordinal)));
|
||||
clock.Advance(TimeSpan.FromSeconds(2));
|
||||
await WaitUntilAsync(() => promptService.Requests.Count == 1);
|
||||
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0, 2, 0], 16000, 1), CancellationToken.None);
|
||||
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment =>
|
||||
segment.Text.Contains("chunk:4", StringComparison.Ordinal)));
|
||||
clock.Advance(TimeSpan.FromSeconds(2));
|
||||
await WaitUntilAsync(() => promptService.Requests.Count == 2);
|
||||
|
||||
Assert.Equal(
|
||||
[TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)],
|
||||
promptService.Requests.Select(request => request.Threshold).ToArray());
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartUsesCurrentOutlookMeetingMetadataWhenAvailable()
|
||||
{
|
||||
@@ -3225,6 +3431,150 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ManualMeetingInactivityClock : IMeetingInactivityClock
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly List<ScheduledDelay> delays = [];
|
||||
|
||||
public ManualMeetingInactivityClock(DateTimeOffset now)
|
||||
{
|
||||
Now = now;
|
||||
}
|
||||
|
||||
public DateTimeOffset Now { get; private set; }
|
||||
|
||||
public int PendingDelayCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return delays.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
{
|
||||
if (delay <= TimeSpan.Zero)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return Task.FromCanceled(cancellationToken);
|
||||
}
|
||||
|
||||
var scheduledDelay = new ScheduledDelay(
|
||||
Now + delay,
|
||||
new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
var registration = cancellationToken.Register(
|
||||
static state =>
|
||||
{
|
||||
var delay = (ScheduledDelay)state!;
|
||||
delay.Completion.TrySetCanceled();
|
||||
},
|
||||
scheduledDelay);
|
||||
scheduledDelay.CancellationRegistration = registration;
|
||||
delays.Add(scheduledDelay);
|
||||
return scheduledDelay.Completion.Task;
|
||||
}
|
||||
}
|
||||
|
||||
public void Advance(TimeSpan duration)
|
||||
{
|
||||
List<ScheduledDelay> due;
|
||||
lock (gate)
|
||||
{
|
||||
Now += duration;
|
||||
due = delays.Where(delay => delay.DueAt <= Now).ToList();
|
||||
foreach (var delay in due)
|
||||
{
|
||||
delays.Remove(delay);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var delay in due)
|
||||
{
|
||||
delay.CancellationRegistration.Dispose();
|
||||
delay.Completion.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ScheduledDelay
|
||||
{
|
||||
public ScheduledDelay(DateTimeOffset dueAt, TaskCompletionSource completion)
|
||||
{
|
||||
DueAt = dueAt;
|
||||
Completion = completion;
|
||||
}
|
||||
|
||||
public DateTimeOffset DueAt { get; }
|
||||
|
||||
public TaskCompletionSource Completion { get; }
|
||||
|
||||
public CancellationTokenRegistration CancellationRegistration { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingMeetingInactivityPromptService : IMeetingInactivityPromptService
|
||||
{
|
||||
private readonly Queue<MeetingInactivityPromptResponse> responses;
|
||||
private TaskCompletionSource promptObserved =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public CapturingMeetingInactivityPromptService(params MeetingInactivityPromptResponse[] responses)
|
||||
{
|
||||
this.responses = new Queue<MeetingInactivityPromptResponse>(responses);
|
||||
}
|
||||
|
||||
public List<MeetingInactivityPromptRequest> Requests { get; } = [];
|
||||
|
||||
public async Task ShowStopPromptAsync(
|
||||
MeetingInactivityPromptRequest request,
|
||||
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
promptObserved.TrySetResult();
|
||||
var response = responses.Count > 0
|
||||
? responses.Dequeue()
|
||||
: MeetingInactivityPromptResponse.Continue;
|
||||
await handleResponseAsync(response, cancellationToken);
|
||||
}
|
||||
|
||||
public Task WaitForPromptAsync()
|
||||
{
|
||||
return promptObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class IgnoringMeetingInactivityPromptService : IMeetingInactivityPromptService
|
||||
{
|
||||
private readonly TaskCompletionSource promptObserved =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public List<MeetingInactivityPromptRequest> Requests { get; } = [];
|
||||
|
||||
public Task ShowStopPromptAsync(
|
||||
MeetingInactivityPromptRequest request,
|
||||
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
promptObserved.TrySetResult();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task WaitForPromptAsync()
|
||||
{
|
||||
return promptObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturedChunkThenCancelAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private readonly AudioChunk chunk;
|
||||
@@ -3257,6 +3607,27 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedSegmentStreamingTranscriptionProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
private readonly TranscriptionSegment segment;
|
||||
|
||||
public FixedSegmentStreamingTranscriptionProvider(TranscriptionSegment segment)
|
||||
{
|
||||
this.segment = segment;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var _ in audio.WithCancellation(cancellationToken))
|
||||
{
|
||||
yield return segment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EchoStreamingTranscriptionProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
public bool FirstChunkWasObservedBeforeSourceCompleted { get; private set; }
|
||||
|
||||
Reference in New Issue
Block a user