Preserve recovery transcripts and add user-approved agent requests
PR and Push Build/Test / build-and-test (push) Successful in 12m0s

Append successful offline recovery with a duplicate-warning marker and retain failed replay sources. Offer newly available microphones through confirmation notifications.

Add project discovery metadata and blocking association approval, plus nonblocking workflow requests that start a separate settings-agent conversation. Include regression tests, OpenSpec changes, and verified local Release startup.
This commit is contained in:
2026-09-25 11:58:34 +02:00
parent e011e36ef7
commit 7b2bcd3631
50 changed files with 2188 additions and 113 deletions
@@ -2170,8 +2170,8 @@ public sealed class RecordingCoordinatorTests
Assert.Equal(1, processed);
Assert.Empty(backlog.Items);
Assert.Equal(["english"], pipelineFactory.ProfileNames);
var segment = Assert.Single(transcriptStore.ReplacedSegments);
Assert.Contains("english chunk:4", segment.Text, StringComparison.Ordinal);
Assert.Single(transcriptStore.Segments, segment => segment.Text.Contains("english chunk:4", StringComparison.Ordinal));
Assert.Contains(transcriptStore.Segments, segment => segment.Text.Contains("may duplicate earlier transcript passages", StringComparison.Ordinal));
Assert.Equal(stoppedAt, noteStore.SavedNote?.Frontmatter.EndTime);
Assert.True(summaryPipeline.WasRun);
Assert.Equal(
@@ -2182,6 +2182,167 @@ public sealed class RecordingCoordinatorTests
workflowEvent.TranscriptLineText?.Contains("english chunk:4", StringComparison.Ordinal) == true);
}
[Theory]
[InlineData(null, false)]
[InlineData("", false)]
[InlineData(" \t", false)]
[InlineData("<Reconnecting... 1/5>", true)]
public async Task OfflineReplayWithoutSpeechPreservesTranscriptAndRecoveryFilesUntilSuccessfulRetry(
string? replayText, bool isMarker)
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
var audioPath = Path.Combine(root, "queued.wav");
using (var writer = new NAudio.Wave.WaveFileWriter(audioPath, new NAudio.Wave.WaveFormat(16000, 16, 1)))
{
writer.Write([1, 0, 2, 0], 0, 4);
}
var options = Options.Create(new MeetingAssistantOptions
{
Vault = new VaultOptions { BaseFolder = root, MeetingNotesFolder = "Notes", TranscriptsFolder = "Transcripts" },
Recording = new RecordingOptions { TemporaryRecordingsFolder = root }
});
var transcriptStore = new VaultTranscriptStore(options, NullLogger<VaultTranscriptStore>.Instance);
var noteStore = new MarkdownMeetingNoteStore(options, NullLogger<MarkdownMeetingNoteStore>.Instance);
var artifactStore = new InMemoryMeetingArtifactStore();
var summaryPipeline = new TranscriptReadingSummaryPipeline();
var workflowEngine = new CapturingMeetingWorkflowEngine();
var backlog = new FileOfflineTranscriptionBacklog(options, NullLogger<FileOfflineTranscriptionBacklog>.Instance);
var startedAt = DateTimeOffset.Now.AddMinutes(-5);
var stoppedAt = startedAt.AddMinutes(4);
var session = await transcriptStore.CreateSessionAsync(options.Value, startedAt, CancellationToken.None);
var artifacts = new MeetingSessionArtifacts(
Path.Combine(root, "meeting.md"), session.TranscriptPath,
Path.Combine(root, "context.md"), Path.Combine(root, "summary.md"));
var note = await noteStore.SaveAsync(new MeetingNote(
artifacts.MeetingNotePath,
new MeetingNoteFrontmatter
{
Title = "Queued meeting", StartTime = startedAt,
Transcript = session.TranscriptPath,
AssistantContext = artifacts.AssistantContextPath,
Summary = artifacts.SummaryPath
},
"Existing user notes"), CancellationToken.None);
await transcriptStore.UpdateMetadataAsync(session, artifacts, note, CancellationToken.None);
await transcriptStore.AppendLineAsync(session, "[00:00:01] Ada: Preserve this live transcript.", CancellationToken.None);
await File.AppendAllTextAsync(session.TranscriptPath, "User correction: keep the original speaker names.");
var originalTranscript = await File.ReadAllBytesAsync(session.TranscriptPath);
var originalBody = MeetingArtifactFrontmatterRenderer.Split(await File.ReadAllTextAsync(session.TranscriptPath)).Body;
var originalNote = await File.ReadAllBytesAsync(note.Path);
var originalAudio = await File.ReadAllBytesAsync(audioPath);
var item = new OfflineTranscriptionBacklogItem(
"queued", audioPath, session.TranscriptPath, note.Path,
artifacts.AssistantContextPath, artifacts.SummaryPath, startedAt, stoppedAt, "default");
await backlog.EnqueueAsync(item, CancellationToken.None);
var replaySegments = new List<TranscriptionSegment>();
if (replayText is not null)
{
replaySegments.Add(new TranscriptionSegment(
TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", replayText,
isMarker ? TranscriptionSegmentKind.Marker : TranscriptionSegmentKind.Speech));
}
var processor = new OfflineTranscriptionBacklogProcessor(
backlog,
new TestSpeechRecognitionPipelineFactory(new SequencedStreamingTranscriptionProvider(replaySegments)),
transcriptStore, noteStore, artifactStore, summaryPipeline, workflowEngine,
CreateRecordingDictationWordProvider(new FixedDictationWordStore([])), options,
NullLogger<OfflineTranscriptionBacklogProcessor>.Instance);
for (var attempt = 0; attempt < 2; attempt++)
{
var processed = await processor.ProcessPendingAsync(CancellationToken.None);
Assert.Equal(originalTranscript, await File.ReadAllBytesAsync(session.TranscriptPath));
Assert.Equal(0, processed);
Assert.Equal(originalNote, await File.ReadAllBytesAsync(note.Path));
Assert.Equal(originalAudio, await File.ReadAllBytesAsync(audioPath));
var reloadedBacklog = new FileOfflineTranscriptionBacklog(options, NullLogger<FileOfflineTranscriptionBacklog>.Instance);
Assert.Equal(item, Assert.Single(await reloadedBacklog.ListAsync(CancellationToken.None)));
Assert.Null(summaryPipeline.Transcript);
Assert.Empty(artifactStore.States);
Assert.Empty(workflowEngine.Events);
}
replaySegments.Clear();
replaySegments.Add(new TranscriptionSegment(
TimeSpan.Zero, TimeSpan.FromSeconds(1), "Ada", "Preserve this live transcript. Recovered speech."));
Assert.Equal(1, await processor.ProcessPendingAsync(CancellationToken.None));
var recoveredTranscript = await File.ReadAllTextAsync(session.TranscriptPath);
Assert.Contains("Recovered speech.", recoveredTranscript, StringComparison.Ordinal);
var recoveredBody = MeetingArtifactFrontmatterRenderer.Split(recoveredTranscript).Body;
Assert.StartsWith(originalBody, recoveredBody, StringComparison.Ordinal);
Assert.Equal(2, recoveredBody.Split("Preserve this live transcript.", StringSplitOptions.None).Length - 1);
var appended = recoveredBody[originalBody.Length..];
Assert.StartsWith(Environment.NewLine + "## Offline transcription recovery", appended, StringComparison.Ordinal);
Assert.Contains("may duplicate earlier transcript passages", appended, StringComparison.Ordinal);
Assert.Contains("Treat repeated passages as the same discussion when summarizing", appended, StringComparison.Ordinal);
Assert.True(appended.IndexOf("may duplicate", StringComparison.Ordinal) < appended.IndexOf("Recovered speech.", StringComparison.Ordinal));
Assert.Equal(stoppedAt, (await noteStore.ReadAsync(note.Path, CancellationToken.None)).Frontmatter.EndTime);
Assert.Equal(recoveredTranscript, summaryPipeline.Transcript);
Assert.Equal(new[] { AssistantContextState.Summarizing, AssistantContextState.Finished }, artifactStore.States);
Assert.Empty(await backlog.ListAsync(CancellationToken.None));
Assert.False(File.Exists(audioPath));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public async Task StopTimeoutKeepsSummaryApprovalAliveWithoutQueuingOfflineReplay()
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
var summary = new WaitingForApprovalSummaryPipeline();
var backlog = new InMemoryOfflineTranscriptionBacklog();
var artifacts = new InMemoryMeetingArtifactStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource, new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(),
artifacts, new InMemoryRecordedAudioStore(), summary,
Options.Create(new MeetingAssistantOptions
{
Recording = new RecordingOptions
{
TranscriptionProvider = "azure-speech",
StopProcessingTimeout = TimeSpan.FromMilliseconds(500)
}
}), NullLogger<MeetingRecordingCoordinator>.Instance, offlineTranscriptionBacklog: backlog);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WaitUntilCapturedAsync();
var stopping = coordinator.StopAsync(CancellationToken.None);
await summary.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
try
{
var status = await stopping.WaitAsync(TimeSpan.FromSeconds(2));
Assert.False(status.IsRecording);
Assert.False(summary.Cancellation.IsCancellationRequested);
Assert.Empty(backlog.Items);
}
finally { summary.Release.TrySetResult(); }
await WaitUntilAsync(() => artifacts.States.Contains(AssistantContextState.Finished), "Approved summary did not finish.");
}
private sealed class WaitingForApprovalSummaryPipeline : IMeetingSummaryPipeline
{
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public CancellationToken Cancellation { get; private set; }
public async Task<MeetingSummaryRunResult> RunAsync(MeetingSessionArtifacts artifacts, CancellationToken cancellationToken)
{
Cancellation = cancellationToken;
Started.TrySetResult();
await Release.Task.WaitAsync(cancellationToken);
return new MeetingSummaryRunResult(artifacts.SummaryPath, "Approved and completed");
}
}
[Fact]
public async Task AbortStopsRecordingDeletesArtifactsAndSkipsSummary()
{
@@ -4548,6 +4709,19 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class TranscriptReadingSummaryPipeline : IMeetingSummaryPipeline
{
public string? Transcript { get; private set; }
public async Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
Transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath, cancellationToken);
return new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok", true);
}
}
private sealed class CapturingMeetingSummaryPipeline : IMeetingSummaryPipeline
{
private readonly bool succeeded;