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
@@ -7,6 +7,37 @@ namespace MeetingAssistant.Tests;
public sealed class MeetingSummaryInstructionBuilderTests
{
[Fact]
public async Task BuilderAddsBoundMetadataWithoutAgentsAndRequestGuidanceToCustomPrompt()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
var artifacts = CreateArtifacts(root);
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, ["Alpha"]);
Directory.CreateDirectory(Path.Combine(projectsRoot, "Alpha"));
await File.WriteAllTextAsync(Path.Combine(projectsRoot, "Alpha", "PROJECT.md"),
"---\nname: Alpha Platform\ndescription: Builds meeting automation\n---\nInternal notes");
await WriteProjectAgentsAsync(projectsRoot, "Unbound", "Do not expose these instructions");
var builder = new MeetingSummaryInstructionBuilder(Options.Create(new MeetingAssistantOptions
{
Agent = new AgentOptions { InitialPrompt = "Custom base." },
Vault = new VaultOptions { ProjectsFolder = projectsRoot }
}));
var instructions = await builder.BuildAsync(artifacts, CancellationToken.None);
Assert.StartsWith("Custom base.", instructions);
Assert.Contains("# Alpha", instructions);
Assert.Contains("Alpha Platform", instructions);
Assert.Contains("Builds meeting automation", instructions);
Assert.Contains("PROJECT.md", instructions);
Assert.Contains("256", instructions);
Assert.Contains("request_project_association", instructions);
Assert.Contains("request_workflow_change", instructions);
Assert.DoesNotContain("Internal notes", instructions);
Assert.DoesNotContain("Do not expose", instructions);
}
[Fact]
public async Task BuilderUsesDefaultPromptWhenConfiguredPromptIsBlank()
{
@@ -75,7 +106,8 @@ public sealed class MeetingSummaryInstructionBuilderTests
var instructions = await builder.BuildAsync(artifacts, CancellationToken.None);
Assert.Equal("Custom summarizer instructions.", instructions);
Assert.StartsWith("Custom summarizer instructions.", instructions);
Assert.Contains("request_project_association", instructions);
}
[Fact]
@@ -96,21 +128,12 @@ public sealed class MeetingSummaryInstructionBuilderTests
var instructions = await builder.BuildAsync(artifacts, CancellationToken.None);
var expected = """
Base.
---
projects:
# Alpha
Alpha instructions.
# Beta
Beta instructions.
""";
Assert.Equal(NormalizeLineEndings(expected), NormalizeLineEndings(instructions));
Assert.StartsWith("Base.", instructions);
Assert.Contains("---\nprojects:", NormalizeLineEndings(instructions));
Assert.Contains("# Alpha", instructions);
Assert.Contains("Alpha instructions.", instructions);
Assert.Contains("# Beta", instructions);
Assert.Contains("Beta instructions.", instructions);
}
private static MeetingSessionArtifacts CreateArtifacts(string root)
@@ -50,6 +50,145 @@ public sealed class MicrophoneAudioSourceTests
await cancellation.CancelAsync();
}
[Fact]
public async Task AcceptedNewMicrophoneReplacesCaptureWithinTheSameStream()
{
var selection = new MicrophoneDeviceSelection();
var devices = new SelectableCaptureDevices(selection);
var prompts = new AcceptingPrompt();
var monitor = new MicrophoneSwitchMonitor(devices, prompts,
NullLogger<MicrophoneSwitchMonitor>.Instance, TimeSpan.FromMilliseconds(10), TimeSpan.FromMinutes(1));
var source = new MicrophoneAudioSource(devices, NullLogger<MicrophoneAudioSource>.Instance,
TimeSpan.Zero, monitor, selection);
using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await using var chunks = source.CaptureAsync(new MeetingAssistantOptions(), lifetime.Token).GetAsyncEnumerator();
try
{
Assert.True(await chunks.MoveNextAsync());
Assert.Equal(1_000, BitConverter.ToInt16(chunks.Current.Pcm));
devices.Available = [new("old", "Built-in"), new("new", "USB headset")];
Assert.Equal("new", (await prompts.Shown.Task.WaitAsync(TimeSpan.FromSeconds(2))).Id);
var nextChunk = chunks.MoveNextAsync().AsTask();
Assert.False(nextChunk.IsCompleted);
Assert.Null(selection.SelectedDeviceId);
prompts.Response.SetResult(true);
Assert.True(await nextChunk.WaitAsync(TimeSpan.FromSeconds(2)));
Assert.Equal(2_000, BitConverter.ToInt16(chunks.Current.Pcm));
Assert.Equal("new", selection.SelectedDeviceId);
Assert.False(lifetime.IsCancellationRequested);
}
finally
{
await lifetime.CancelAsync();
}
}
[Fact]
public async Task EndingMeetingDuringCreationDisposesAbandonedCapture()
{
using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var abandonedCapture = new DisposableAudioSource();
var factory = new CallbackCaptureFactory(() =>
{
lifetime.Cancel();
return abandonedCapture;
});
var source = new MicrophoneAudioSource(factory, NullLogger<MicrophoneAudioSource>.Instance);
await using var chunks = source.CaptureAsync(lifetime.Token).GetAsyncEnumerator();
Assert.False(await chunks.MoveNextAsync());
Assert.True(abandonedCapture.Disposed);
}
private sealed class CallbackCaptureFactory(Func<IMeetingAudioSource> create) : IMicrophoneCaptureSourceFactory
{
public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options) => create();
}
[Fact]
public async Task AcceptedSwitchDuringCreationDisposesAbandonedCapture()
{
var selection = new MicrophoneDeviceSelection();
var devices = new SwitchDuringCreationDevices(selection);
var prompts = new AcceptingPrompt();
prompts.Response.SetResult(true);
var monitor = new MicrophoneSwitchMonitor(devices, prompts,
NullLogger<MicrophoneSwitchMonitor>.Instance, TimeSpan.FromMilliseconds(10), TimeSpan.FromMinutes(1));
var source = new MicrophoneAudioSource(devices, NullLogger<MicrophoneAudioSource>.Instance,
TimeSpan.Zero, monitor, selection);
using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await using var chunks = source.CaptureAsync(new MeetingAssistantOptions(), lifetime.Token).GetAsyncEnumerator();
try
{
Assert.True(await chunks.MoveNextAsync());
Assert.Equal("new", selection.SelectedDeviceId);
Assert.Equal(2_000, BitConverter.ToInt16(chunks.Current.Pcm));
Assert.True(devices.AbandonedCapture.Disposed);
}
finally
{
await lifetime.CancelAsync();
}
}
private sealed class SwitchDuringCreationDevices(MicrophoneDeviceSelection selection)
: IMicrophoneDeviceProvider, IMicrophoneCaptureSourceFactory
{
private MicrophoneDevice[] available = [new("old", "Built-in")];
private int creations;
private volatile bool switchObserved;
public DisposableAudioSource AbandonedCapture { get; } = new();
public IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones()
{
if (selection.SelectedDeviceId == "new") switchObserved = true;
return Volatile.Read(ref available);
}
public MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options)
=> new(GetAvailableMicrophones(), available[0]);
public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options)
{
if (++creations > 1) return new ActiveAudioSource(Pcm16(2_000));
Volatile.Write(ref available, [new("old", "Built-in"), new("new", "USB headset")]);
// Let the acceptance callback finish before returning the already-opened source.
if (!SpinWait.SpinUntil(() => switchObserved, TimeSpan.FromSeconds(2)))
throw new TimeoutException("The new microphone was not selected during capture creation.");
return AbandonedCapture;
}
}
private sealed class DisposableAudioSource : IMeetingAudioSource, IDisposable
{
public bool Disposed { get; private set; }
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
=> new ActiveAudioSource(Pcm16(1_000)).CaptureAsync(cancellationToken);
public void Dispose() => Disposed = true;
}
private sealed class AcceptingPrompt : IMicrophoneSwitchPromptService
{
public TaskCompletionSource<MicrophoneDevice> Shown { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource<bool> Response { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<bool> ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken)
{
Shown.TrySetResult(microphone);
return Response.Task.WaitAsync(cancellationToken);
}
}
private sealed class SelectableCaptureDevices(MicrophoneDeviceSelection selection)
: IMicrophoneDeviceProvider, IMicrophoneCaptureSourceFactory
{
public volatile MicrophoneDevice[] Available = [new("old", "Built-in")];
public IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones() => Available;
public MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options)
=> new(Available, selection.Resolve(options.Recording.MicrophoneDeviceId, Available[0], Available));
public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options)
=> new ActiveAudioSource(Pcm16(GetMicrophoneSnapshot(options).Current!.Id == "new" ? (short)2_000 : (short)1_000));
}
private static byte[] Pcm16(short sample)
{
return BitConverter.GetBytes(sample);
@@ -0,0 +1,163 @@
using System.Threading.Channels;
using MeetingAssistant.Recording;
using Microsoft.Extensions.Logging.Abstractions;
namespace MeetingAssistant.Tests;
public sealed class MicrophoneSwitchMonitorTests
{
[Fact]
public async Task CancellationDuringConfirmationDeviceLookupCannotSwitchMicrophone()
{
using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var devices = new MutableDevices();
var prompts = new ImmediatelyAcceptedPrompt(() => devices.BeforeRead = lifetime.Cancel);
var monitor = new MicrophoneSwitchMonitor(devices, prompts,
NullLogger<MicrophoneSwitchMonitor>.Instance, TimeSpan.FromMilliseconds(10), TimeSpan.FromMinutes(1));
var switched = false;
var monitoring = monitor.RunAsync((_, _) => switched = true, lifetime.Token);
devices.Available = [new("usb", "USB microphone")];
await monitoring.WaitAsync(TimeSpan.FromSeconds(2));
Assert.True(prompts.WasShown);
Assert.False(switched);
}
[Fact]
public async Task OffersOnlyNewDevicesOnceAndSwitchesOnlyAfterAcceptance()
{
var oldDevice = new MicrophoneDevice("old", "Built-in microphone");
var newDevice = new MicrophoneDevice("new", "USB headset");
var devices = new MutableDevices { Available = [oldDevice] };
var prompts = new ControlledPrompts();
var monitor = CreateMonitor(devices, prompts);
var switched = new TaskCompletionSource<MicrophoneDevice>(TaskCreationOptions.RunContinuationsAsynchronously);
using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var monitoring = monitor.RunAsync((device, _) => switched.TrySetResult(device), lifetime.Token);
try
{
devices.Available = [oldDevice, newDevice];
var prompt = await prompts.NextAsync();
Assert.Equal(newDevice, prompt.Device);
Assert.False(switched.Task.IsCompleted);
await devices.WaitForPollsAsync();
Assert.False(prompts.HasMore);
prompt.Response.SetResult(true);
Assert.Equal(newDevice, await switched.Task.WaitAsync(TimeSpan.FromSeconds(2)));
await devices.WaitForPollsAsync();
Assert.False(prompts.HasMore);
}
finally
{
await lifetime.CancelAsync();
await monitoring;
}
}
[Theory]
[InlineData("declined")]
[InlineData("removed")]
[InlineData("expired")]
[InlineData("ended")]
public async Task InvalidOrDeclinedPromptsCannotChangeSelection(string outcome)
{
var device = new MicrophoneDevice("usb", "USB microphone");
var devices = new MutableDevices();
var prompts = new ControlledPrompts();
var monitor = CreateMonitor(devices, prompts,
outcome == "expired" ? TimeSpan.FromMilliseconds(30) : null);
var switched = false;
using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var monitoring = monitor.RunAsync((_, _) => switched = true, lifetime.Token);
try
{
devices.Available = [device];
var prompt = await prompts.NextAsync();
if (outcome == "declined")
{
prompt.Response.SetResult(false);
await devices.WaitForPollsAsync();
}
else
{
if (outcome == "removed") devices.Available = [];
if (outcome == "ended") await lifetime.CancelAsync();
var canceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var registration = prompt.Cancellation.Register(() => canceled.TrySetResult());
await canceled.Task.WaitAsync(TimeSpan.FromSeconds(2));
prompt.Response.TrySetResult(true);
}
await lifetime.CancelAsync();
await monitoring;
Assert.False(switched);
}
finally
{
await lifetime.CancelAsync();
await monitoring;
}
}
private static MicrophoneSwitchMonitor CreateMonitor(MutableDevices devices, ControlledPrompts prompts,
TimeSpan? promptLifetime = null) => new(devices, prompts,
NullLogger<MicrophoneSwitchMonitor>.Instance, TimeSpan.FromMilliseconds(10),
promptLifetime ?? TimeSpan.FromMinutes(1));
private sealed class MutableDevices : IMicrophoneDeviceProvider
{
private readonly Channel<bool> polls = Channel.CreateUnbounded<bool>();
public volatile MicrophoneDevice[] Available = [];
public Action? BeforeRead { get; set; }
public IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones()
{
BeforeRead?.Invoke();
polls.Writer.TryWrite(true);
return Available;
}
public MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options)
=> new(Available, Available.FirstOrDefault());
public async Task WaitForPollsAsync()
{
while (polls.Reader.TryRead(out _)) { }
for (var i = 0; i < 3; i++)
await polls.Reader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2));
}
}
private sealed class ImmediatelyAcceptedPrompt(Action beforeAcceptance) : IMicrophoneSwitchPromptService
{
public bool WasShown { get; private set; }
public Task<bool> ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken)
{
WasShown = true;
beforeAcceptance();
return Task.FromResult(true);
}
}
private sealed class ControlledPrompts : IMicrophoneSwitchPromptService
{
private readonly Channel<Prompt> requests = Channel.CreateUnbounded<Prompt>();
public bool HasMore => requests.Reader.TryPeek(out _);
public Task<Prompt> NextAsync() => requests.Reader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2));
public async Task<bool> ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken)
{
var prompt = new Prompt(microphone, cancellationToken);
requests.Writer.TryWrite(prompt);
return await prompt.Response.Task.WaitAsync(cancellationToken);
}
}
private sealed record Prompt(MicrophoneDevice Device, CancellationToken Cancellation)
{
public TaskCompletionSource<bool> Response { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
}
}
@@ -1,10 +1,90 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary;
using System.Text.Json;
namespace MeetingAssistant.Tests;
public sealed class ProjectKnowledgeToolTests
{
[Theory]
[InlineData("name: Incomplete project")]
[InlineData("description: Missing name")]
[InlineData("name: ' '\ndescription: Missing name")]
public async Task CatalogUsesLegacyFallbackForIncompleteMetadata(string frontmatter)
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectRoot = Path.Combine(root, "Projects", "Alpha");
Directory.CreateDirectory(projectRoot);
await File.WriteAllTextAsync(Path.Combine(projectRoot, "PROJECT.md"), $"---\n{frontmatter}\n---\nNotes");
var tools = new MeetingSummaryTools(CreateArtifacts(root),
new MeetingAssistantOptions { Vault = { ProjectsFolder = Path.Combine(root, "Projects") } });
using var catalog = JsonDocument.Parse(await tools.ListProjects());
var project = Assert.Single(catalog.RootElement.EnumerateArray());
Assert.Equal("Alpha", project.GetProperty("displayname").GetString());
Assert.Equal("", project.GetProperty("description").GetString());
}
[Fact]
public async Task ProjectMetadataWriteEnforcesDescriptionLimitAndPreservesOtherContent()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectRoot = Path.Combine(root, "Projects", "Alpha");
Directory.CreateDirectory(projectRoot);
var artifacts = CreateArtifacts(root);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await File.WriteAllTextAsync(artifacts.MeetingNotePath, "---\nprojects: Alpha\n---");
var tools = new MeetingSummaryTools(artifacts,
new MeetingAssistantOptions { Vault = { ProjectsFolder = Path.Combine(root, "Projects") } });
var original = $"---\nname: Alpha Platform\ndescription: {new string('a', 256)}\nowner: Ada\n---\nUser-authored project notes.";
Assert.Equal("Alpha/PROJECT.md", await tools.WriteProjectFile("Alpha", "PROJECT.md", original));
var rejected = await tools.WriteProjectFile("Alpha", "PROJECT.md",
$"description: {new string('b', 257)}", from: 3, to: 3);
Assert.StartsWith("Refused:", rejected);
Assert.Equal(original, await File.ReadAllTextAsync(Path.Combine(projectRoot, "PROJECT.md")));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("Alpha", "PROJECT.md", "No metadata", replace_file: true));
Assert.Equal("Alpha/PROJECT.md", await tools.WriteProjectFile("Alpha", "PROJECT.md",
"description: Updated project purpose", from: 3, to: 3));
var updated = await tools.ReadProjectFile("Alpha", "PROJECT.md");
Assert.Contains("owner: Ada", updated);
Assert.Contains("User-authored project notes.", updated);
Assert.Contains("Updated project purpose", await tools.ListProjects());
}
[Fact]
public async Task CatalogListsAllProjectsWithMetadataAndLegacyFallbackWithoutGrantingReadAccess()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
Directory.CreateDirectory(Path.Combine(projectsRoot, "Alpha"));
Directory.CreateDirectory(Path.Combine(projectsRoot, "Legacy"));
Directory.CreateDirectory(Path.Combine(projectsRoot, "Malformed"));
await File.WriteAllTextAsync(Path.Combine(projectsRoot, "Alpha", "PROJECT.md"),
"---\nname: Alpha Platform\ndescription: Meeting automation\n---\nPrivate project notes");
await File.WriteAllTextAsync(Path.Combine(projectsRoot, "Alpha", "AGENTS.md"), "Private instructions");
await File.WriteAllTextAsync(Path.Combine(projectsRoot, "Malformed", "PROJECT.md"), "---\nname: [broken\n---");
var tools = new MeetingSummaryTools(CreateArtifacts(root),
new MeetingAssistantOptions { Vault = { ProjectsFolder = projectsRoot } });
var catalog = await tools.ListProjects();
using var json = JsonDocument.Parse(catalog);
var projects = json.RootElement.EnumerateArray().ToArray();
Assert.Equal(3, projects.Length);
Assert.Equal("Alpha", projects[0].GetProperty("id").GetString());
Assert.Equal("Alpha Platform", projects[0].GetProperty("displayname").GetString());
Assert.Equal("Meeting automation", projects[0].GetProperty("description").GetString());
Assert.Equal("Legacy", projects[1].GetProperty("displayname").GetString());
Assert.Equal("", projects[1].GetProperty("description").GetString());
Assert.Equal("Malformed", projects[2].GetProperty("displayname").GetString());
Assert.Equal("", projects[2].GetProperty("description").GetString());
Assert.DoesNotContain("Private", catalog);
Assert.Equal("", await tools.ReadProjectFile("Alpha", "AGENTS.md"));
}
[Fact]
public async Task ToolsOperateOnProjectsBoundInMeetingFrontmatter()
{
@@ -48,7 +128,7 @@ public sealed class ProjectKnowledgeToolTests
}
});
Assert.Equal("MeetingAssistant", await tools.ListProjects());
Assert.Contains("MeetingAssistant", await tools.ListProjects());
Assert.Equal("README.md\nnotes/context.md", await tools.ListProjectFiles("MeetingAssistant"));
Assert.Equal("Second alpha line\nThird beta line", await tools.ReadProjectFile("MeetingAssistant", "README.md", 2, 99));
@@ -56,15 +136,15 @@ public sealed class ProjectKnowledgeToolTests
Assert.Equal("MeetingAssistant/notes/summary.md", writeResult);
Assert.Equal("# Project Update", await File.ReadAllTextAsync(Path.Combine(meetingAssistantRoot, "notes", "summary.md")));
Assert.Equal("IgnoredProject/ignored.md", await tools.WriteProjectFile("IgnoredProject", "ignored.md", "changed"));
Assert.Equal("alpha should not be searched\nchanged", await File.ReadAllTextAsync(Path.Combine(ignoredRoot, "ignored.md")));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("IgnoredProject", "ignored.md", "changed"));
Assert.Equal("alpha should not be searched", await File.ReadAllTextAsync(Path.Combine(ignoredRoot, "ignored.md")));
Assert.Equal(
"README.md:2 Second alpha line",
await tools.Search("alpha"));
}
[Fact]
public async Task ListProjectsAcceptsScalarProjectFrontmatter()
public async Task ProjectFileAccessAcceptsScalarProjectFrontmatter()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
@@ -88,7 +168,8 @@ public sealed class ProjectKnowledgeToolTests
}
});
Assert.Equal("MeetingAssistant", await tools.ListProjects());
Assert.Equal("MeetingAssistant/notes.md", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "Bound project"));
Assert.Equal("Bound project", await tools.ReadProjectFile("MeetingAssistant", "notes.md"));
}
[Fact]
@@ -100,8 +181,11 @@ public sealed class ProjectKnowledgeToolTests
Directory.CreateDirectory(projectRoot);
var projectFile = Path.Combine(projectRoot, "notes.md");
await File.WriteAllTextAsync(projectFile, "one\ntwo\nthree\nfour");
var artifacts = CreateArtifacts(root);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await File.WriteAllTextAsync(artifacts.MeetingNotePath, "---\nprojects: MeetingAssistant\n---\n");
var tools = new MeetingSummaryTools(
CreateArtifacts(root),
artifacts,
new MeetingAssistantOptions
{
Vault =
@@ -132,8 +216,13 @@ public sealed class ProjectKnowledgeToolTests
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
Directory.CreateDirectory(Path.Combine(projectsRoot, "MeetingAssistant"));
var artifacts = CreateArtifacts(root);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await File.WriteAllTextAsync(artifacts.MeetingNotePath, "---\nprojects: MeetingAssistant\n---\n");
var outsidePath = Path.Combine(projectsRoot, "outside.md");
await File.WriteAllTextAsync(outsidePath, "Preserve outside content");
var tools = new MeetingSummaryTools(
CreateArtifacts(root),
artifacts,
new MeetingAssistantOptions
{
Vault =
@@ -144,6 +233,7 @@ public sealed class ProjectKnowledgeToolTests
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MissingProject", "notes.md", "content"));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "../outside.md", "content"));
Assert.Equal("Preserve outside content", await File.ReadAllTextAsync(outsidePath));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1, to: 1, insert: 1));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1, to: 1, replace_file: true));
@@ -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;
@@ -0,0 +1,209 @@
using System.Threading.Channels;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Notifications;
using MeetingAssistant.Summary;
using Microsoft.Extensions.Logging.Abstractions;
using MeetingAssistant.Workflow;
namespace MeetingAssistant.Tests;
public sealed class SummaryAgentRequestTests
{
[Theory]
[InlineData("denied")]
[InlineData("expired")]
[InlineData("shutdown")]
public async Task WorkflowDenialExpirationAndShutdownRemovePromptWithoutStartingAgent(string outcome)
{
var prompts = new ControlledPrompts();
var editor = new RecordingEditor();
var approvals = new AgentApprovalService(prompts, NullLogger<AgentApprovalService>.Instance,
outcome == "expired" ? TimeSpan.FromMilliseconds(100) : TimeSpan.FromMinutes(10));
using var requests = new WorkflowChangeRequestService(approvals, editor, NullLogger<WorkflowChangeRequestService>.Instance);
await requests.StartAsync(CancellationToken.None);
try
{
Assert.StartsWith("Requested", requests.Request("Add association rule", "Given Alpha, then associate Alpha.", "meeting.md"));
var prompt = await prompts.NextAsync();
if (outcome == "denied") prompt.Response.SetResult(false);
if (outcome == "shutdown") await requests.StopAsync(CancellationToken.None);
await prompt.Removed.Task.WaitAsync(TimeSpan.FromSeconds(2));
prompt.Response.TrySetResult(true);
}
finally { await requests.StopAsync(CancellationToken.None); }
Assert.Empty(editor.Prompts);
Assert.StartsWith("Refused", requests.Request("Too late", "Do something", "meeting.md"));
}
[Fact]
public async Task InvalidRequestsAreRefusedBeforeNotification()
{
var fixture = await CreateAsync();
var original = await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath);
var prompts = new ControlledPrompts();
var approvals = new AgentApprovalService(prompts, NullLogger<AgentApprovalService>.Instance);
using var requests = new WorkflowChangeRequestService(approvals, new RecordingEditor(), NullLogger<WorkflowChangeRequestService>.Instance);
var tools = new MeetingSummaryTools(fixture.Artifacts, fixture.Options, approvals: approvals, workflowRequests: requests);
Assert.StartsWith("Refused", await tools.RequestProjectAssociation("../Alpha", "Relevant"));
Assert.StartsWith("Refused", await tools.RequestProjectAssociation("Alpha", new string('a', 101)));
Assert.StartsWith("Refused", await tools.RequestProjectAssociation("Alpha", "two\nlines"));
Assert.StartsWith("Refused", await tools.RequestProjectAssociation("Alpha", ""));
Assert.StartsWith("Refused", tools.RequestWorkflowChange(new string('a', 101), "Detailed scenario"));
Assert.StartsWith("Refused", tools.RequestWorkflowChange("Intent", " "));
Assert.StartsWith("Refused", tools.RequestWorkflowChange("two\nlines", "Detailed scenario"));
Assert.False(prompts.HasMore);
Assert.Equal(original, await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath));
}
[Fact]
public async Task WorkflowRequestReturnsImmediatelyAndApprovalStartsItsDetailedPromptOnce()
{
var fixture = await CreateAsync();
var prompts = new ControlledPrompts();
var editor = new RecordingEditor();
var approvals = new AgentApprovalService(prompts, NullLogger<AgentApprovalService>.Instance);
using var requests = new WorkflowChangeRequestService(approvals, editor, NullLogger<WorkflowChangeRequestService>.Instance);
await requests.StartAsync(CancellationToken.None);
try
{
const string detail = "Given a meeting with Alpha in its title, when metadata arrives, then add project Alpha.";
var tools = new MeetingSummaryTools(fixture.Artifacts, fixture.Options, workflowRequests: requests);
var result = tools.RequestWorkflowChange("Associate Alpha meetings automatically", detail);
Assert.StartsWith("Requested", result);
var prompt = await prompts.NextAsync();
Assert.Contains("Workflow change requested: Associate Alpha meetings automatically", prompt.Prompt.Title);
Assert.Empty(editor.Prompts);
await tools.WriteSummary("The summary can finish while approval is pending.", "Summary finished");
Assert.True(tools.SummaryWasWritten);
prompt.Response.SetResult(true);
Assert.Equal(detail, await editor.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)));
Assert.False(prompt.Response.TrySetResult(true));
Assert.Single(editor.Prompts);
await prompt.Removed.Task.WaitAsync(TimeSpan.FromSeconds(2));
}
finally { await requests.StopAsync(CancellationToken.None); }
}
[Theory]
[InlineData("denied")]
[InlineData("expired")]
[InlineData("canceled")]
[InlineData("removed")]
public async Task UnapprovedOrStaleAssociationNeverChangesNoteOrProjectAccess(string outcome)
{
var fixture = await CreateAsync();
var original = await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath);
var prompts = new ControlledPrompts();
var approvals = new AgentApprovalService(prompts, NullLogger<AgentApprovalService>.Instance,
outcome == "expired" ? TimeSpan.FromMilliseconds(100) : TimeSpan.FromMinutes(10));
var tools = new MeetingSummaryTools(fixture.Artifacts, fixture.Options, approvals: approvals);
using var cancellation = new CancellationTokenSource();
var request = tools.RequestProjectAssociation("Alpha", "Relevant project", cancellation.Token);
var prompt = await prompts.NextAsync();
if (outcome == "denied") prompt.Response.SetResult(false);
if (outcome == "canceled") await cancellation.CancelAsync();
if (outcome == "removed")
{
Directory.Delete(Path.Combine(fixture.Options.Vault.ProjectsFolder, "Alpha"), recursive: true);
prompt.Response.SetResult(true);
}
if (outcome == "canceled")
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => request);
else
Assert.DoesNotContain("Approved", await request.WaitAsync(TimeSpan.FromSeconds(2)));
await prompt.Removed.Task.WaitAsync(TimeSpan.FromSeconds(2));
prompt.Response.TrySetResult(true);
Assert.Equal(original, await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath));
Assert.Equal("", await tools.ReadProjectFile("Alpha", "AGENTS.md"));
}
[Fact]
public async Task AssociationWaitsForApprovalThenPreservesLatestNoteAndUnlocksProjectWithInstructions()
{
var fixture = await CreateAsync();
var prompts = new ControlledPrompts();
var approvals = new AgentApprovalService(prompts, NullLogger<AgentApprovalService>.Instance);
var tools = new MeetingSummaryTools(fixture.Artifacts, fixture.Options, approvals: approvals);
var request = tools.RequestProjectAssociation("alpha", "Transcript discusses the Alpha launch");
var prompt = await prompts.NextAsync();
Assert.Contains("Alpha Platform", prompt.Prompt.Message);
Assert.Contains("Transcript discusses", prompt.Prompt.Message);
Assert.Equal(TimeSpan.FromMinutes(10), prompt.Prompt.Timeout);
Assert.False(request.IsCompleted);
Assert.Equal("", await tools.ReadProjectFile("Alpha", "AGENTS.md"));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("Alpha", "notes.md", "unauthorized"));
var latestNote = "---\ntitle: Edited by user\nprojects: Existing\ncustom: keep-me\n---\n\nUser's latest notes.";
await File.WriteAllTextAsync(fixture.Artifacts.MeetingNotePath, latestNote);
prompt.Response.SetResult(true);
var result = await request.WaitAsync(TimeSpan.FromSeconds(2));
Assert.StartsWith("Approved", result);
Assert.Contains("Alpha instructions", result);
Assert.Contains("Alpha Platform", result);
var note = await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath);
Assert.Contains("Edited by user", note);
Assert.Contains("custom: keep-me", note);
Assert.Contains("User's latest notes.", note);
Assert.Contains("Existing", note);
Assert.Contains("Alpha", note);
Assert.Empty(Directory.EnumerateFiles(Path.GetDirectoryName(fixture.Artifacts.MeetingNotePath)!, "*.tmp"));
Assert.Equal("Alpha instructions", await tools.ReadProjectFile("Alpha", "AGENTS.md"));
Assert.Equal("Alpha/notes.md", await tools.WriteProjectFile("Alpha", "notes.md", "approved"));
Assert.Contains("notes.md:1 approved", await tools.Search("approved", ["Alpha"]));
Assert.StartsWith("Approved", await tools.RequestProjectAssociation("Alpha", "Already associated"));
Assert.False(prompts.HasMore);
}
private static async Task<(MeetingSessionArtifacts Artifacts, MeetingAssistantOptions Options)> CreateAsync()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var project = Path.Combine(root, "Projects", "Alpha");
Directory.CreateDirectory(project);
var artifacts = new MeetingSessionArtifacts(Path.Combine(root, "meeting.md"), Path.Combine(root, "transcript.md"),
Path.Combine(root, "context.md"), Path.Combine(root, "summary.md"));
await File.WriteAllTextAsync(artifacts.MeetingNotePath, "---\ntitle: Planning\nprojects: []\n---\nUser notes.");
await File.WriteAllTextAsync(Path.Combine(project, "PROJECT.md"),
"---\nname: Alpha Platform\ndescription: Meeting automation\n---");
await File.WriteAllTextAsync(Path.Combine(project, "AGENTS.md"), "Alpha instructions");
return (artifacts, new MeetingAssistantOptions { Vault = { ProjectsFolder = Path.Combine(root, "Projects") } });
}
private sealed class RecordingEditor : IWorkflowRulesEditorWindowService
{
public List<string> Prompts { get; } = [];
public TaskCompletionSource<string> Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public void Show() { }
public void ShowWithPrompt(string initialPrompt)
{
Prompts.Add(initialPrompt);
Started.TrySetResult(initialPrompt);
}
}
private sealed class ControlledPrompts : IAgentApprovalPromptService
{
private readonly Channel<PendingPrompt> requests = Channel.CreateUnbounded<PendingPrompt>();
public bool HasMore => requests.Reader.TryPeek(out _);
public Task<PendingPrompt> NextAsync() => requests.Reader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2));
public async Task<bool> ConfirmAsync(AgentApprovalPrompt prompt, CancellationToken cancellationToken)
{
var request = new PendingPrompt(prompt, cancellationToken);
requests.Writer.TryWrite(request);
try { return await request.Response.Task.WaitAsync(cancellationToken); }
finally { request.Removed.TrySetResult(); }
}
}
private sealed record PendingPrompt(AgentApprovalPrompt Prompt, CancellationToken Cancellation)
{
public TaskCompletionSource<bool> Response { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource Removed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
}
}
@@ -940,6 +940,30 @@ public sealed class WorkflowRulesEditorTests
AssertCompletedActivity(viewModel, ["Thinking..."], "Updated rules.");
}
[Fact]
public async Task ApprovedWorkflowPromptStartsImmediatelyInItsOwnConversation()
{
var existing = new WorkflowRulesEditorChatViewModel(new BlockingRulesEditorPipeline("Other work"))
{
Draft = "My unfinished draft"
};
var pipeline = new BlockingRulesEditorPipeline("Workflow updated.");
var requested = new WorkflowRulesEditorChatViewModel(pipeline);
const string prompt = "Given Alpha in the meeting title, when metadata arrives, then associate project Alpha.";
var task = requested.SendPromptAsync(prompt);
await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
Assert.True(requested.IsThinking);
Assert.Empty(pipeline.LastConversation);
Assert.Equal(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, prompt), requested.Messages[0].Message);
Assert.Equal("My unfinished draft", existing.Draft);
Assert.Empty(existing.Messages);
pipeline.Release.SetResult();
await task.WaitAsync(TimeSpan.FromSeconds(2));
Assert.False(requested.IsThinking);
}
[Fact]
public async Task ViewModelShowsAgentErrorWhenRequestTimesOut()
{