Public Access
Merge remote-tracking branch 'origin/main' into renovate/microsoft.agents.ai.openai-1.x
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -2386,12 +2386,17 @@ public sealed class RecordingCoordinatorTests
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleToDifferentLaunchProfileBuffersAudioCapturedWhilePreviousPipelineDrains()
|
||||
[Theory]
|
||||
[InlineData("default", "english")]
|
||||
[InlineData("english", "default")]
|
||||
public async Task ProfileSwitchDrainsFinalTranscriptAndResumesBufferedAudioWithoutBlockingNotifications(
|
||||
string initialProfile,
|
||||
string targetProfile)
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var pipelineFactory = new BlockingProfileSwitchSpeechRecognitionPipelineFactory();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var promptService = new IgnoringMeetingInactivityPromptService();
|
||||
var launchProfiles = CreateLaunchProfiles(
|
||||
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default"),
|
||||
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english"));
|
||||
@@ -2406,13 +2411,16 @@ public sealed class RecordingCoordinatorTests
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(launchProfiles.GetRequiredProfile(null).Options),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
inactivityPromptService: promptService,
|
||||
launchProfiles: launchProfiles);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await coordinator.StartAsync(initialProfile, CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await transcriptStore.WaitForTextAsync("default chunk:2");
|
||||
await transcriptStore.WaitForTextAsync($"{initialProfile} chunk:2");
|
||||
await WaitUntilAsync(() => promptService.DismissAllCount == 1);
|
||||
|
||||
var switchTask = coordinator.ToggleAsync("english", CancellationToken.None);
|
||||
using var switchCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var switchTask = coordinator.ToggleAsync(targetProfile, switchCancellation.Token);
|
||||
await pipelineFactory.WaitUntilFirstPipelineDrainIsBlockedAsync();
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0, 2, 0, 3, 0], 16000, 1), CancellationToken.None);
|
||||
|
||||
@@ -2420,10 +2428,21 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
pipelineFactory.ReleaseFirstPipelineDrain();
|
||||
await switchTask.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await transcriptStore.WaitForTextAsync("english chunk:6");
|
||||
await transcriptStore.WaitForTextAsync($"{targetProfile} chunk:6");
|
||||
await WaitUntilAsync(() => promptService.DismissAllCount == 3);
|
||||
|
||||
Assert.Equal([null, "english"], pipelineFactory.ProfileNames);
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
Assert.Equal(targetProfile, coordinator.CurrentStatus.LaunchProfile);
|
||||
Assert.Equal([initialProfile, targetProfile], pipelineFactory.ProfileNames);
|
||||
var segments = transcriptStore.Segments.ToList();
|
||||
var finalResultIndex = segments.FindIndex(segment => segment.Text == "Final result emitted while switching profiles.");
|
||||
var markerIndex = segments.FindIndex(segment => segment.Text.Contains($"Transcription profile changed to {targetProfile}", StringComparison.Ordinal));
|
||||
Assert.True(finalResultIndex >= 0 && finalResultIndex < markerIndex);
|
||||
|
||||
await coordinator.ToggleAsync(initialProfile, CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0, 2, 0], 16000, 1), CancellationToken.None);
|
||||
await transcriptStore.WaitForTextAsync($"{initialProfile} chunk:4");
|
||||
await coordinator.StopAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.False(coordinator.CurrentStatus.IsRecording);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -5140,6 +5159,11 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
drainBlocked.TrySetResult();
|
||||
await releaseDrain.Task.WaitAsync(cancellationToken);
|
||||
yield return new TranscriptionSegment(
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromSeconds(3),
|
||||
"Unknown",
|
||||
"Final result emitted while switching profiles.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
<PackageReference Include="Microsoft.CognitiveServices.Speech.Extension.MAS" Version="$(MicrosoftSpeechVersion)" ExcludeAssets="build" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
|
||||
<PackageReference Include="NAudio" Version="2.3.0" />
|
||||
<PackageReference Include="NCalcSync" Version="7.1.0" />
|
||||
<PackageReference Include="NCalcSync" Version="7.2.0" />
|
||||
<PackageReference Include="RazorLight" Version="2.3.1" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.11" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.12" />
|
||||
<PackageReference Include="Whisper.net" Version="1.9.1" />
|
||||
<PackageReference Include="Whisper.net.Runtime" Version="1.9.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="18.1.0" />
|
||||
|
||||
@@ -805,7 +805,9 @@ public sealed class MeetingRecordingCoordinator
|
||||
run.RecordTranscriptActivity(segment, inactivityClock.Now);
|
||||
if (!string.IsNullOrWhiteSpace(segment.Text))
|
||||
{
|
||||
await DismissInactivityPromptsForRunAsync(run, cancellationToken);
|
||||
// Switching profiles holds the coordinator gate while draining this reader.
|
||||
// Notification cleanup must not make transcript draining wait for that gate.
|
||||
_ = DismissInactivityPromptsForRunAsync(run, run.CaptureCancellation);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -813,6 +815,8 @@ public sealed class MeetingRecordingCoordinator
|
||||
private async Task DismissInactivityPromptsForRunAsync(
|
||||
RecordingRun run,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
@@ -829,6 +833,11 @@ public sealed class MeetingRecordingCoordinator
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Pending cleanup is no longer needed after this run stops capturing.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DismissInactivityPromptsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -46,6 +46,8 @@ The inactivity safeguard will skip transcript-inactivity prompting and its ordin
|
||||
|
||||
The coordinator will call dismissal only after a non-empty live segment has been durably appended, matching the user's observable meaning of a new transcription being written. Notification callbacks will verify that their originating run is still current before changing recording state.
|
||||
|
||||
Transcript processing requests notification dismissal without awaiting it. Profile switching holds the coordinator gate while draining the old transcript reader, so awaiting dismissal from that reader would create a circular wait on the same gate. Deferred cleanup retains the gate and current-run check, uses the capture cancellation token, and handles cancellation when capture stops. Durable transcript activity still invalidates stale notification actions immediately, before deferred cleanup runs.
|
||||
|
||||
### Model pause as one toggling tray action
|
||||
|
||||
The active-recording tray menu will show `Pause transcription` while running and `Unpause transcription` while paused. It will stay in the fine-grained controls section below the dedicated `Finish meeting` section. The inactivity toast will add `Pause transcription` alongside the existing Yes/No stop controls.
|
||||
|
||||
@@ -121,6 +121,8 @@ If the user selects pause from the stop prompt, Meeting Assistant SHALL pause tr
|
||||
|
||||
When a new non-empty live transcript segment is written, Meeting Assistant SHALL dismiss all outstanding transcript-inactivity notifications and invalidate their pending actions.
|
||||
|
||||
Notification dismissal SHALL NOT block processing the final transcript segments emitted while switching launch profiles. Any deferred dismissal SHALL recheck that its originating run is still the active recording before dismissing notifications.
|
||||
|
||||
While transcription is intentionally paused, Meeting Assistant SHALL NOT show transcript-inactivity prompts and SHALL NOT apply the ordinary transcript-inactivity auto-stop threshold.
|
||||
|
||||
Meeting Assistant SHALL normally stop a meeting that remains continuously paused for the configured maximum pause duration, defaulting to 4 hours, without first showing a transcript-inactivity notification. This maximum continuous-pause cutoff SHALL remain active when ordinary transcript-inactivity prompting and auto-stop are disabled.
|
||||
@@ -205,6 +207,15 @@ When a recording stops normally and its meeting note, transcript, and assistant
|
||||
- **WHEN** the older meeting writes a late transcript segment
|
||||
- **THEN** the newer meeting's notification and pending actions remain active
|
||||
|
||||
#### Scenario: Final transcript during a profile switch dismisses notifications without blocking the switch
|
||||
- **GIVEN** a meeting is actively recording with either the `default` or `english` profile
|
||||
- **WHEN** the user switches to the other profile
|
||||
- **AND** the old recognizer emits a final non-empty transcript segment while draining
|
||||
- **THEN** Meeting Assistant writes that final segment before the profile-switch marker
|
||||
- **AND** completes the switch and transcribes buffered audio in the same meeting
|
||||
- **AND** dismisses the originating active run's outstanding inactivity notifications
|
||||
- **AND** subsequent profile switches and normal meeting completion remain available
|
||||
|
||||
#### Scenario: Empty stopped recording is cleaned up
|
||||
- **GIVEN** a recording is active
|
||||
- **AND** the meeting note, transcript, and assistant context only contain generated default content
|
||||
|
||||
@@ -36,3 +36,14 @@
|
||||
- [x] 6.2 Scope notification dismissal to the active run and move activity invalidation after durable transcript append, with overlapping-run and blocked-write regression tests.
|
||||
- [x] 6.3 Make pause-notification activity validation atomic and make tray pause/unpause actions intent-specific, with race and stale-menu tests.
|
||||
- [x] 6.4 Complete an independent simplification review and rerun all required validation before commit.
|
||||
|
||||
## 7. Profile-switch deadlock repair
|
||||
|
||||
- [x] 7.1 Reproduce final transcript delivery during profile switching in both directions through the coordinator's public interface.
|
||||
- [x] 7.2 Decouple notification dismissal from transcript draining while preserving current-run checks and cancellation handling.
|
||||
- [x] 7.3 Verify buffered transcription, subsequent controls, notification lifecycle tests, the full suite, Windows build, and strict OpenSpec validation.
|
||||
- [x] 7.4 Deploy with explicit restart authorization, verify the live health/control endpoints, and record the operational verification limits.
|
||||
|
||||
Verification on 2026-09-16: both profile-switch regression cases failed with the original circular wait and passed after the repair. All 520 solution tests passed on the final run. An unchanged audio-mixing timing test failed during the first full run, then passed alone and in the final full run. Strict OpenSpec validation and the Windows Release publish passed. The executable is staged at `tmp/meeting-assistant-runtime/run-20260916-142827-profile-switch-fix`.
|
||||
|
||||
Operational verification on 2026-09-16: after explicit user authorization, the deadlocked process was killed and the staged Windows release started as PID 48716. `/health` returned `ok`, `/recording/status` returned idle, and the process path confirmed the fixed release. The old meeting's summary was requested through `/meetings/summary/retry`. Profile-switch behavior was verified through the public coordinator regression tests rather than recording a new live meeting. The existing 25,092,144-byte WAV and meeting artifacts were backed up under `tmp/profile-switch-recovery-20260916`; audio held only in the killed process after the switch was not recovered. The meeting context records that transcription gap.
|
||||
|
||||
Reference in New Issue
Block a user