Add inactivity safeguard and speaker diagnostics
PR and Push Build/Test / build-and-test (push) Failing after 8m31s

This commit is contained in:
2026-06-02 13:16:02 +02:00
parent c56ecb6ab3
commit 0e9d525b63
24 changed files with 1780 additions and 117 deletions
@@ -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; }