Archive completed meeting assistant changes
PR and Push Build/Test / build-and-test (push) Successful in 9m19s

This commit is contained in:
2026-06-26 13:15:47 +02:00
parent 7d16b0cad7
commit aecef30627
72 changed files with 2914 additions and 406 deletions
@@ -1,4 +1,5 @@
using MeetingAssistant.Calendar;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Recording;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@@ -22,6 +23,94 @@ public sealed class CalendarRecordingPromptSchedulerTests
Assert.Equal(0, harness.Recorder.StopCount);
}
[Fact]
public async Task AcceptedPromptStartsRecordingWithPromptedMeetingMetadata()
{
var harness = CreateHarness(isRecording: false);
var selectedMetadata = new MeetingMetadata(
"Selected planning",
["Ada"],
"Selected agenda",
DateTimeOffset.Parse("2026-06-03T10:30:00+00:00"));
var otherMetadata = new MeetingMetadata(
"Other planning",
["Grace"],
"Other agenda",
DateTimeOffset.Parse("2026-06-03T10:40:00+00:00"));
var selectedMeeting = CreateMeeting(
harness.Clock,
id: "teams-selected",
subject: "Selected planning",
metadata: selectedMetadata);
var otherMeeting = CreateMeeting(
harness.Clock,
id: "teams-other",
subject: "Other planning",
startOffset: TimeSpan.FromMinutes(40),
metadata: otherMetadata);
harness.Provider.Meetings = [selectedMeeting, otherMeeting];
await SyncThenPromptAsync(harness, selectedMeeting);
Assert.Equal(1, harness.Recorder.StartCount);
Assert.Same(selectedMetadata, harness.Recorder.StartMetadata.Single());
Assert.Equal(1, harness.Recorder.PromptStartCount);
}
[Fact]
public async Task AcceptedPromptWithoutMetadataStillUsesPromptStartPath()
{
var harness = CreateHarness(isRecording: false);
var meeting = CreateMeeting(
harness.Clock,
id: "teams-without-metadata",
subject: "No metadata",
metadata: null);
harness.Provider.Meetings = [meeting];
await SyncThenPromptAsync(harness, meeting);
Assert.Equal(1, harness.Recorder.StartCount);
Assert.Null(harness.Recorder.StartMetadata.Single());
Assert.Equal(1, harness.Recorder.PromptStartCount);
}
[Fact]
public async Task AcceptingDisplayedPromptsOutOfOrderUsesAcceptedPromptMetadata()
{
var harness = CreateHarness(isRecording: false, autoAcceptPrompts: false);
var firstMetadata = new MeetingMetadata(
"First planning",
["Ada"],
"First agenda",
DateTimeOffset.Parse("2026-06-03T10:30:00+00:00"));
var secondMetadata = new MeetingMetadata(
"Second planning",
["Grace"],
"Second agenda",
DateTimeOffset.Parse("2026-06-03T10:30:00+00:00"));
var firstMeeting = CreateMeeting(
harness.Clock,
id: "teams-first",
subject: "First planning",
metadata: firstMetadata);
var secondMeeting = CreateMeeting(
harness.Clock,
id: "teams-second",
subject: "Second planning",
metadata: secondMetadata);
harness.Provider.Meetings = [firstMeeting, secondMeeting];
await harness.Scheduler.SyncOnceAsync(CancellationToken.None);
harness.Clock.Now = firstMeeting.Start;
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
await harness.PromptService.RespondAsync(secondMeeting, MeetingStartPromptResponse.Record);
Assert.Equal([firstMeeting, secondMeeting], harness.PromptService.PromptedMeetings);
Assert.Equal(1, harness.Recorder.StartCount);
Assert.Same(secondMetadata, harness.Recorder.StartMetadata.Single());
}
[Fact]
public async Task AcceptedPromptStopsActiveRecordingBeforeStartingNewRecording()
{
@@ -34,6 +123,25 @@ public sealed class CalendarRecordingPromptSchedulerTests
Assert.Equal(["stop", "start"], harness.Recorder.Commands);
}
[Fact]
public async Task CanceledCachedMeetingDoesNotPromptRecording()
{
var harness = CreateHarness(isRecording: false);
var canceledMeeting = CreateMeeting(
harness.Clock,
id: "teams-canceled",
subject: "Canceled project sync",
isCanceled: true);
harness.Provider.Meetings = [canceledMeeting];
await harness.Scheduler.SyncOnceAsync(CancellationToken.None);
harness.Clock.Now = canceledMeeting.Start;
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
Assert.Empty(harness.PromptService.PromptedMeetings);
Assert.Equal(0, harness.Recorder.StartCount);
}
[Fact]
public async Task DisabledCalendarPromptsDoNotQueryCalendar()
{
@@ -115,23 +223,28 @@ public sealed class CalendarRecordingPromptSchedulerTests
string id = "teams-1",
string subject = "Project sync",
TimeSpan? startOffset = null,
TimeSpan? duration = null)
TimeSpan? duration = null,
MeetingMetadata? metadata = null,
bool isCanceled = false)
{
var start = clock.Now.Add(startOffset ?? TimeSpan.FromMinutes(30));
return new CalendarMeeting(
id,
subject,
start,
start.Add(duration ?? TimeSpan.FromMinutes(30)));
start.Add(duration ?? TimeSpan.FromMinutes(30)),
metadata,
isCanceled);
}
private static SchedulerHarness CreateHarness(
bool isRecording = false,
bool enabled = true)
bool enabled = true,
bool autoAcceptPrompts = true)
{
var clock = new ManualCalendarPromptClock(new DateTimeOffset(2026, 6, 3, 9, 30, 0, TimeSpan.Zero));
var provider = new RecordingCalendarMeetingProvider([]);
var promptService = new AcceptingMeetingStartPromptService();
var promptService = new CapturingMeetingStartPromptService(autoAcceptPrompts);
var recorder = new RecordingPromptRecorder(isRecording);
var scheduler = new CalendarRecordingPromptScheduler(
Options.Create(new MeetingAssistantOptions
@@ -155,7 +268,7 @@ public sealed class CalendarRecordingPromptSchedulerTests
private sealed record SchedulerHarness(
CalendarRecordingPromptScheduler Scheduler,
RecordingCalendarMeetingProvider Provider,
AcceptingMeetingStartPromptService PromptService,
CapturingMeetingStartPromptService PromptService,
RecordingPromptRecorder Recorder,
ManualCalendarPromptClock Clock);
@@ -179,8 +292,16 @@ public sealed class CalendarRecordingPromptSchedulerTests
}
}
private sealed class AcceptingMeetingStartPromptService : IMeetingStartPromptService
private sealed class CapturingMeetingStartPromptService : IMeetingStartPromptService
{
private readonly bool autoAccept;
private readonly List<PendingPrompt> pendingPrompts = [];
public CapturingMeetingStartPromptService(bool autoAccept)
{
this.autoAccept = autoAccept;
}
public List<CalendarMeeting> PromptedMeetings { get; } = [];
public async Task ShowPromptAsync(
@@ -189,8 +310,24 @@ public sealed class CalendarRecordingPromptSchedulerTests
CancellationToken cancellationToken)
{
PromptedMeetings.Add(request.Meeting);
await handleResponseAsync(MeetingStartPromptResponse.Record, cancellationToken);
pendingPrompts.Add(new PendingPrompt(request.Meeting, handleResponseAsync));
if (autoAccept)
{
await handleResponseAsync(MeetingStartPromptResponse.Record, cancellationToken);
}
}
public Task RespondAsync(
CalendarMeeting meeting,
MeetingStartPromptResponse response)
{
var prompt = pendingPrompts.Single(pending => ReferenceEquals(pending.Meeting, meeting));
return prompt.HandleResponseAsync(response, CancellationToken.None);
}
private sealed record PendingPrompt(
CalendarMeeting Meeting,
Func<MeetingStartPromptResponse, CancellationToken, Task> HandleResponseAsync);
}
private sealed class RecordingPromptRecorder : IMeetingPromptRecordingController
@@ -206,16 +343,35 @@ public sealed class CalendarRecordingPromptSchedulerTests
public int StopCount { get; private set; }
public int PromptStartCount { get; private set; }
public List<string> Commands { get; } = [];
public List<MeetingMetadata?> StartMetadata { get; } = [];
public Task<RecordingStatus> StartAsync(CancellationToken cancellationToken)
{
return StartRecordingAsync(null);
}
private Task<RecordingStatus> StartRecordingAsync(
MeetingMetadata? metadata)
{
StartCount++;
Commands.Add("start");
StartMetadata.Add(metadata);
CurrentStatus = Status(isRecording: true);
return Task.FromResult(CurrentStatus);
}
public Task<RecordingStatus> StartFromPromptAsync(
MeetingMetadata? metadata,
CancellationToken cancellationToken)
{
PromptStartCount++;
return StartRecordingAsync(metadata);
}
public Task<RecordingStatus> StopAsync(CancellationToken cancellationToken)
{
StopCount++;
@@ -16,9 +16,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
[Fact]
public async Task ExtractUsesAgentEndpointAndModelWhenOcrEndpointAndModelAreBlank()
{
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
await File.WriteAllBytesAsync(screenshotPath, [1, 2, 3]);
var screenshotPath = await CreateScreenshotAsync([1, 2, 3]);
var handler = new RecordingHandler("""
{
"output": [
@@ -75,9 +73,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
[Fact]
public async Task ExtractUsesScreenshotOcrEndpointAndModelWhenConfigured()
{
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
await File.WriteAllBytesAsync(screenshotPath, [4, 5, 6]);
var screenshotPath = await CreateScreenshotAsync([4, 5, 6]);
var handler = new RecordingHandler("""{ "output_text": "OCR result" }""");
var client = new LiteLlmScreenshotOcrClient(
() => handler,
@@ -116,9 +112,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
[Fact]
public async Task ExtractParsesCropMetadataAndOmitsMetadataFromReturnedText()
{
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
await File.WriteAllBytesAsync(screenshotPath, CreatePngBytes(8, 6));
var screenshotPath = await CreateScreenshotAsync(CreatePngBytes(8, 6));
var handler = new RecordingHandler("""
{
"output_text": "Slide text\n\n```json\n{ \"crop\": { \"x\": 1, \"y\": 2, \"width\": 3, \"height\": 4 } }\n```"
@@ -150,6 +144,67 @@ public sealed class LiteLlmScreenshotOcrClientTests
StringComparison.Ordinal);
}
[Fact]
public async Task ExtractParsesAttendeeMetadataAndOmitsMetadataFromReturnedText()
{
var screenshotPath = await CreateScreenshotAsync([1, 2, 3]);
var handler = new RecordingHandler("""
{
"output_text": "Visible participant tiles: Ada and Grace.\n\n```json\n{ \"crop\": null, \"attendees\": [\"Ada Lovelace\", \"Grace Hopper\"] }\n```"
}
""");
var client = new LiteLlmScreenshotOcrClient(
() => handler,
NullLogger<LiteLlmScreenshotOcrClient>.Instance);
var options = new MeetingAssistantOptions
{
Agent =
{
Key = "agent-key"
}
};
var result = await client.ExtractAsync(
screenshotPath,
"Extract screenshot.",
options,
CancellationToken.None);
Assert.Equal("Visible participant tiles: Ada and Grace.", result.Text);
Assert.Equal(["Ada Lovelace", "Grace Hopper"], result.Attendees);
}
[Fact]
public async Task ExtractIgnoresMalformedAttendeesMetadataAndStillParsesCrop()
{
var screenshotPath = await CreateScreenshotAsync(CreatePngBytes(8, 6));
var handler = new RecordingHandler("""
{
"output_text": "Slide text\n\n```json\n{ \"crop\": { \"x\": 1, \"y\": 2, \"width\": 3, \"height\": 4 }, \"attendees\": \"Ada\" }\n```"
}
""");
var client = new LiteLlmScreenshotOcrClient(
() => handler,
NullLogger<LiteLlmScreenshotOcrClient>.Instance);
var options = new MeetingAssistantOptions
{
Agent =
{
Key = "agent-key"
}
};
var result = await client.ExtractAsync(
screenshotPath,
"Extract screenshot.",
options,
CancellationToken.None);
Assert.Equal("Slide text", result.Text);
Assert.Equal(new ScreenshotCropCoordinates(1, 2, 3, 4), result.Crop);
Assert.Empty(result.Attendees);
}
private sealed class RecordingHandler : HttpMessageHandler
{
private readonly string responseBody;
@@ -193,6 +248,14 @@ public sealed class LiteLlmScreenshotOcrClientTests
bitmap.Save(stream, ImageFormat.Png);
return stream.ToArray();
}
private static async Task<string> CreateScreenshotAsync(byte[] bytes)
{
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
await File.WriteAllBytesAsync(screenshotPath, bytes);
return screenshotPath;
}
}
#pragma warning restore CA1416
@@ -1,6 +1,8 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Screenshots;
using MeetingAssistant.Speakers;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System.Drawing;
using System.Drawing.Imaging;
@@ -130,6 +132,165 @@ public sealed class MeetingScreenshotServiceTests
Assert.Contains("Shared screen text", context);
}
[Fact]
public async Task CaptureAddsOcrAttendeesToMeetingNoteThroughCanonicalizer()
{
var fixture = await ScreenshotFixture.CreateAsync(
options =>
{
options.Screenshots.Ocr.Enabled = true;
},
attendees: ["Ada Lovelace"]);
var ocr = new CapturingScreenshotOcrClient(
"Visible participant tiles: Ada, Grace, and Ada again.",
attendees: ["Ada L.", "Grace Hopper", "Ada Lovelace"]);
var canonicalizer = new MappingAttendeeCanonicalizer(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Ada L."] = "Ada Lovelace"
});
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
ocr,
canonicalizer);
await service.CaptureAsync(
fixture.Artifacts,
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
fixture.Options,
CancellationToken.None);
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
Assert.Equal(["Ada Lovelace", "Grace Hopper"], meeting.Frontmatter.Attendees);
Assert.Contains(canonicalizer.Requests, request =>
request.SequenceEqual(["Ada Lovelace", "Ada L.", "Grace Hopper", "Ada Lovelace"]));
}
[Fact]
public async Task CaptureWritesRetryLinkWhenOcrFails()
{
var fixture = await ScreenshotFixture.CreateAsync(options =>
{
options.Api.PublicBaseUrl = "http://localhost:5090";
options.Screenshots.Ocr.Enabled = true;
});
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
new ThrowingScreenshotOcrClient("vision offline"));
var result = await service.CaptureAsync(
fixture.Artifacts,
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
fixture.Options,
CancellationToken.None);
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
var screenshotId = ExtractScreenshotOcrId(context);
Assert.Contains("_OCR failed: vision offline_", context);
Assert.Contains("<!-- screenshot-ocr:", context);
Assert.Contains("<!-- /screenshot-ocr:", context);
Assert.Contains("[Retry screenshot OCR](http://localhost:5090/meetings/screenshot-ocr/retry?", context);
Assert.Contains($"screenshotId={screenshotId}", context);
Assert.Contains($"screenshotPath={Uri.EscapeDataString(result.ScreenshotPath)}", context);
Assert.Contains($"assistantContextPath={Uri.EscapeDataString(fixture.Artifacts.AssistantContextPath)}", context);
}
[Fact]
public async Task RetryOcrReplacesFailureForSameScreenshot()
{
var fixture = await ScreenshotFixture.CreateAsync(options =>
{
options.Screenshots.Ocr.Enabled = true;
});
var ocr = new SequencedScreenshotOcrClient(
new InvalidOperationException("vision offline"),
new ScreenshotOcrResult("Retried OCR text", null, ["Grace Hopper"]));
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
ocr);
var result = await service.CaptureAsync(
fixture.Artifacts,
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
fixture.Options,
CancellationToken.None);
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
var failedContext = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
var screenshotId = ExtractScreenshotOcrId(failedContext);
var retry = await service.TriggerOcrRetryAsync(
fixture.Artifacts,
result.ScreenshotPath,
screenshotId,
fixture.Options,
CancellationToken.None);
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
Assert.NotNull(retry);
Assert.Equal(result.ScreenshotPath, retry.ScreenshotPath);
Assert.Equal(screenshotId, retry.ScreenshotId);
Assert.Equal([result.ScreenshotPath, result.ScreenshotPath], ocr.ScreenshotPaths);
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
Assert.Contains("### OCR", context);
Assert.Contains("Retried OCR text", context);
Assert.DoesNotContain("vision offline", context);
Assert.DoesNotContain("Retry screenshot OCR", context);
Assert.DoesNotContain("<!-- screenshot-ocr:", context);
}
[Fact]
public async Task ProcessMeetingNoteImageEmbedsAppendsContextAndRunsOcrWithoutCropOrAttendees()
{
var fixture = await ScreenshotFixture.CreateAsync(
options =>
{
options.Screenshots.Ocr.Enabled = true;
},
attendees: ["Ada Lovelace"],
userNotes: "Discussed ![[whiteboard.png]] and ![Diagram](attachments/diagram.png).");
var noteFolder = Path.GetDirectoryName(fixture.Artifacts.MeetingNotePath)!;
var attachmentsFolder = Path.Combine(noteFolder, "attachments");
Directory.CreateDirectory(attachmentsFolder);
var whiteboardPath = Path.Combine(noteFolder, "whiteboard.png");
var diagramPath = Path.Combine(attachmentsFolder, "diagram.png");
await File.WriteAllBytesAsync(whiteboardPath, [1, 2, 3]);
await File.WriteAllBytesAsync(diagramPath, [4, 5, 6]);
var ocr = new CapturingScreenshotOcrClient(
"Manual image OCR",
new ScreenshotCropCoordinates(1, 1, 2, 2),
["Grace Hopper"]);
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
ocr);
var originalMeetingNote = await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath);
var result = await service.ProcessMeetingNoteImageEmbedsAsync(
fixture.Artifacts,
fixture.Options,
CancellationToken.None);
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
Assert.Equal(2, result.QueuedCount);
Assert.Equal(2, ocr.CallCount);
Assert.Equal([whiteboardPath, diagramPath], ocr.ScreenshotPaths);
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
Assert.Contains("## Meeting Note Image", context);
Assert.Contains("Image from meeting note.", context);
Assert.Contains("Original embed: `![[whiteboard.png]]`", context);
Assert.Contains("Original embed: `![Diagram](attachments/diagram.png)`", context);
Assert.Contains("whiteboard.png", context);
Assert.Contains("diagram.png", context);
Assert.Contains("Manual image OCR", context);
Assert.DoesNotContain("Cropped screenshot", context);
Assert.Equal(originalMeetingNote, await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath));
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees);
}
[Fact]
public async Task WaitForPendingOcrWaitsForRunningScreenshotOcr()
{
@@ -192,11 +353,13 @@ public sealed class MeetingScreenshotServiceTests
private ScreenshotFixture(
MeetingAssistantOptions options,
MeetingSessionArtifacts artifacts,
MarkdownMeetingArtifactStore artifactStore)
MarkdownMeetingArtifactStore artifactStore,
MarkdownMeetingNoteStore noteStore)
{
Options = options;
Artifacts = artifacts;
ArtifactStore = artifactStore;
NoteStore = noteStore;
}
public MeetingAssistantOptions Options { get; }
@@ -205,8 +368,12 @@ public sealed class MeetingScreenshotServiceTests
public MarkdownMeetingArtifactStore ArtifactStore { get; }
public MarkdownMeetingNoteStore NoteStore { get; }
public static async Task<ScreenshotFixture> CreateAsync(
Action<MeetingAssistantOptions>? configure = null)
Action<MeetingAssistantOptions>? configure = null,
IReadOnlyList<string>? attendees = null,
string userNotes = "")
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var options = new MeetingAssistantOptions
@@ -218,6 +385,9 @@ public sealed class MeetingScreenshotServiceTests
}
};
configure?.Invoke(options);
var noteStore = new MarkdownMeetingNoteStore(
Microsoft.Extensions.Options.Options.Create(options),
NullLogger<MarkdownMeetingNoteStore>.Instance);
var artifacts = new MeetingSessionArtifacts(
Path.Combine(root, "Notes", "meeting.md"),
Path.Combine(root, "Transcripts", "transcript.md"),
@@ -225,6 +395,7 @@ public sealed class MeetingScreenshotServiceTests
Path.Combine(root, "Summaries", "summary.md"));
var artifactStore = new MarkdownMeetingArtifactStore(
NullLogger<MarkdownMeetingArtifactStore>.Instance);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
var meeting = MeetingNoteTemplate.Create(
"Planning",
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
@@ -232,24 +403,33 @@ public sealed class MeetingScreenshotServiceTests
assistantContextPath: artifacts.AssistantContextPath,
summaryPath: artifacts.SummaryPath) with
{
Path = artifacts.MeetingNotePath
Path = artifacts.MeetingNotePath,
UserNotes = userNotes
};
meeting.Frontmatter.Attendees = attendees?.ToList() ?? [];
var savedMeeting = await noteStore.SaveAsync(
meeting,
options,
CancellationToken.None);
await artifactStore.CreateAssistantContextAsync(
artifacts,
meeting,
savedMeeting,
"",
null,
CancellationToken.None);
return new ScreenshotFixture(options, artifacts, artifactStore);
return new ScreenshotFixture(options, artifacts, artifactStore, noteStore);
}
public MeetingScreenshotService CreateService(
IActiveWindowScreenshotCapture capture,
IScreenshotOcrClient ocrClient)
IScreenshotOcrClient ocrClient,
ISpeakerIdentityAttendeeCanonicalizer? attendeeCanonicalizer = null)
{
return new MeetingScreenshotService(
capture,
ArtifactStore,
NoteStore,
attendeeCanonicalizer ?? PassthroughSpeakerIdentityAttendeeCanonicalizer.Instance,
ocrClient,
NullLogger<MeetingScreenshotService>.Instance);
}
@@ -276,15 +456,18 @@ public sealed class MeetingScreenshotServiceTests
public CapturingScreenshotOcrClient(
string text = "",
ScreenshotCropCoordinates? crop = null)
ScreenshotCropCoordinates? crop = null,
IReadOnlyList<string>? attendees = null)
{
result = new ScreenshotOcrResult(text, crop);
result = new ScreenshotOcrResult(text, crop, attendees ?? []);
}
public int CallCount { get; private set; }
public string? Prompt { get; private set; }
public List<string> ScreenshotPaths { get; } = [];
public Task<ScreenshotOcrResult> ExtractAsync(
string screenshotPath,
string prompt,
@@ -293,6 +476,7 @@ public sealed class MeetingScreenshotServiceTests
{
CallCount++;
Prompt = prompt;
ScreenshotPaths.Add(screenshotPath);
return Task.FromResult(result);
}
}
@@ -320,7 +504,79 @@ public sealed class MeetingScreenshotServiceTests
public void Release(string value)
{
result.TrySetResult(new ScreenshotOcrResult(value, null));
result.TrySetResult(new ScreenshotOcrResult(value, null, []));
}
}
private sealed class ThrowingScreenshotOcrClient : IScreenshotOcrClient
{
private readonly string message;
public ThrowingScreenshotOcrClient(string message)
{
this.message = message;
}
public Task<ScreenshotOcrResult> ExtractAsync(
string screenshotPath,
string prompt,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
throw new InvalidOperationException(message);
}
}
private sealed class SequencedScreenshotOcrClient : IScreenshotOcrClient
{
private readonly Queue<object> results;
public SequencedScreenshotOcrClient(params object[] results)
{
this.results = new Queue<object>(results);
}
public List<string> ScreenshotPaths { get; } = [];
public Task<ScreenshotOcrResult> ExtractAsync(
string screenshotPath,
string prompt,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
ScreenshotPaths.Add(screenshotPath);
var result = results.Dequeue();
if (result is Exception exception)
{
throw exception;
}
return Task.FromResult((ScreenshotOcrResult)result);
}
}
private sealed class MappingAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer
{
private readonly IReadOnlyDictionary<string, string> aliases;
public MappingAttendeeCanonicalizer(IReadOnlyDictionary<string, string> aliases)
{
this.aliases = aliases;
}
public List<IReadOnlyList<string>> Requests { get; } = [];
public Task<IReadOnlyList<string>> CanonicalizeAsync(
IReadOnlyList<string> attendees,
CancellationToken cancellationToken)
{
Requests.Add(attendees.ToList());
var result = attendees
.Select(attendee => aliases.TryGetValue(attendee, out var canonical) ? canonical : attendee)
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<string>>(result);
}
}
@@ -336,6 +592,17 @@ public sealed class MeetingScreenshotServiceTests
bitmap.Save(stream, ImageFormat.Png);
return stream.ToArray();
}
private static string ExtractScreenshotOcrId(string context)
{
const string prefix = "<!-- screenshot-ocr:";
var start = context.IndexOf(prefix, StringComparison.Ordinal);
Assert.True(start >= 0);
start += prefix.Length;
var end = context.IndexOf(" -->", start, StringComparison.Ordinal);
Assert.True(end > start);
return context[start..end];
}
}
#pragma warning restore CA1416
@@ -0,0 +1,54 @@
using MeetingAssistant.Recording;
namespace MeetingAssistant.Tests;
public sealed class MicrophoneSelectionTests
{
[Fact]
public void BlankConfiguredMicrophoneUsesDefaultDevice()
{
var selection = new MicrophoneDeviceSelection();
var selected = selection.Resolve(
configuredDeviceId: null,
new MicrophoneDevice("default-id", "default microphone"),
[
new MicrophoneDevice("default-id", "default microphone"),
new MicrophoneDevice("other-id", "other microphone")
]);
Assert.Equal("default-id", selected?.Id);
}
[Fact]
public void ConfiguredMicrophoneUsesMatchingDevice()
{
var selection = new MicrophoneDeviceSelection();
var selected = selection.Resolve(
"other-id",
new MicrophoneDevice("default-id", "default microphone"),
[
new MicrophoneDevice("default-id", "default microphone"),
new MicrophoneDevice("other-id", "other microphone")
]);
Assert.Equal("other-id", selected?.Id);
}
[Fact]
public void RuntimeMicrophoneSelectionOverridesConfiguredDevice()
{
var selection = new MicrophoneDeviceSelection();
selection.Select("runtime-id");
var selected = selection.Resolve(
"configured-id",
new MicrophoneDevice("default-id", "default microphone"),
[
new MicrophoneDevice("configured-id", "configured microphone"),
new MicrophoneDevice("runtime-id", "runtime microphone")
]);
Assert.Equal("runtime-id", selected?.Id);
}
}
@@ -8,7 +8,7 @@ public sealed class OutlookClassicMeetingMetadataProviderTests
[Fact]
public void ExtractAgendaStopsBeforeTeamsJoinInformation()
{
var agenda = OutlookClassicMeetingMetadataProvider.ExtractAgenda(
var agenda = OutlookClassicAppointmentMetadata.ExtractAgenda(
"""
Review current prototype
Decide next backend
@@ -25,7 +25,7 @@ public sealed class OutlookClassicMeetingMetadataProviderTests
[Fact]
public void NormalizeAttendeesDeduplicatesOrganizerAndRecipientEmail()
{
var attendees = OutlookClassicMeetingMetadataProvider.NormalizeAttendees([
var attendees = OutlookClassicAppointmentMetadata.NormalizeAttendees([
"Marcus.Altmann@sew-eurodrive.de",
"Marcus.Altmann@sew-eurodrive.de <Marcus.Altmann@sew-eurodrive.de>",
"Schweigert, Manuel",
@@ -37,5 +37,14 @@ public sealed class OutlookClassicMeetingMetadataProviderTests
"Schweigert, Manuel"
], attendees);
}
[Theory]
[InlineData("Canceled: Project sync")]
[InlineData("Cancelled: Project sync")]
[InlineData("Abgesagt: Project sync")]
public void SubjectIndicatesCancellationRecognizesOutlookCanceledPrefixes(string subject)
{
Assert.True(OutlookClassicCom.SubjectIndicatesCancellation(subject));
}
}
#endif
@@ -784,6 +784,88 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartWithPromptedMetadataBypassesLookupAndRunsWorkflowWithMetadata()
{
var audioSource = new ControlledAudioSource();
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\prompted-metadata-meeting.md");
var artifactStore = new InMemoryMeetingArtifactStore();
var metadataProvider = new CountingMeetingMetadataProvider();
var workflowEngine = new MetadataObservingWorkflowEngine(() => noteStore.SavedNote);
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
noteStore,
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
meetingMetadataProvider: metadataProvider,
meetingWorkflowEngine: workflowEngine);
var promptedMetadata = new MeetingMetadata(
"Prompted Architecture Sync",
["Ada <ada@example.com>"],
"Prompted agenda",
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"));
await coordinator.StartFromPromptAsync(promptedMetadata, CancellationToken.None);
await WaitUntilAsync(() => workflowEngine.ObservedEvents.Any(entry =>
entry.Type == MeetingWorkflowEventType.StateTransition &&
entry.Title == "Prompted Architecture Sync"));
Assert.Equal(0, metadataProvider.CallCount);
Assert.Equal("Prompted Architecture Sync", noteStore.SavedNote?.Frontmatter.Title);
Assert.Equal(["Ada <ada@example.com>"], noteStore.SavedNote?.Frontmatter.Attendees);
Assert.Equal("Prompted agenda", artifactStore.Agenda);
Assert.Equal(DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"), artifactStore.ScheduledEnd);
Assert.Collection(
workflowEngine.ObservedEvents.Where(entry =>
entry.Type is MeetingWorkflowEventType.Created or MeetingWorkflowEventType.StateTransition),
created =>
{
Assert.Equal(MeetingWorkflowEventType.Created, created.Type);
Assert.StartsWith("Meeting ", created.Title, StringComparison.Ordinal);
},
transition =>
{
Assert.Equal(MeetingWorkflowEventType.StateTransition, transition.Type);
Assert.Equal("Prompted Architecture Sync", transition.Title);
Assert.Equal(AssistantContextState.CollectingMetadata, transition.FromState);
Assert.Equal(AssistantContextState.Transcribing, transition.ToState);
});
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartFromPromptWithoutMetadataBypassesLookup()
{
var audioSource = new ControlledAudioSource();
var metadataProvider = new CountingMeetingMetadataProvider();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
meetingMetadataProvider: metadataProvider);
await coordinator.StartFromPromptAsync(null, CancellationToken.None);
await Task.Delay(100);
Assert.Equal(0, metadataProvider.CallCount);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartCanonicalizesOutlookMeetingAttendeesBeforeWritingNote()
{
@@ -1353,7 +1435,9 @@ public sealed class RecordingCoordinatorTests
Assert.Equal([null, "english"], pipelineFactory.ProfileNames);
Assert.Contains(transcriptStore.Segments, segment =>
segment.Speaker == "System" &&
segment.Text.Contains("Transcription profile changed to english", StringComparison.Ordinal));
segment.Text.Contains(
"Transcription profile changed to english. Speaker recognition and identities reset.",
StringComparison.Ordinal));
Assert.Null(summaryPipeline.Artifacts);
await WaitUntilAsync(() => metadataProvider.CallCount == 1);
@@ -2121,6 +2205,44 @@ public sealed class RecordingCoordinatorTests
Assert.NotNull(summaryPipeline.Artifacts);
}
[Fact]
public async Task StopProcessesMeetingNoteImageEmbedsBeforeSummarizing()
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
var artifactStore = new InMemoryMeetingArtifactStore();
var summaryPipeline = new CapturingMeetingSummaryPipeline();
var screenshotService = new BlockingMeetingScreenshotService();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
summaryPipeline,
Options.Create(CreateOptionsWithoutFinalizer()),
NullLogger<MeetingRecordingCoordinator>.Instance,
screenshotService: screenshotService,
meetingNoteImageOcrService: screenshotService);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WaitUntilCapturedAsync();
var stop = coordinator.StopAsync(CancellationToken.None);
await screenshotService.WaitUntilMeetingNoteImageProcessingStartedAsync();
await screenshotService.WaitUntilOcrWaitStartedAsync();
Assert.DoesNotContain(AssistantContextState.Summarizing, artifactStore.States);
Assert.Null(summaryPipeline.Artifacts);
screenshotService.ReleaseOcrWait();
await stop;
Assert.Contains(AssistantContextState.Summarizing, artifactStore.States);
Assert.NotNull(summaryPipeline.Artifacts);
}
[Fact]
public async Task StopMarksAssistantContextErrorWhenSummaryFails()
{
@@ -2866,6 +2988,46 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class MetadataObservingWorkflowEngine : IMeetingWorkflowEngine
{
private readonly Func<MeetingNote?> getMeetingNote;
public MetadataObservingWorkflowEngine(Func<MeetingNote?> getMeetingNote)
{
this.getMeetingNote = getMeetingNote;
}
public List<ObservedWorkflowEvent> ObservedEvents { get; } = [];
public Task RunAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var note = getMeetingNote();
ObservedEvents.Add(new ObservedWorkflowEvent(
workflowEvent.Type,
note?.Frontmatter.Title,
workflowEvent.FromState,
workflowEvent.ToState));
return Task.CompletedTask;
}
public Task<string> TransformTranscriptLineAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return Task.FromResult(workflowEvent.TranscriptLineText ?? "");
}
public sealed record ObservedWorkflowEvent(
MeetingWorkflowEventType Type,
string? Title,
AssistantContextState? FromState,
AssistantContextState? ToState);
}
private sealed class FailingFirstTranscriptLineWorkflowEngine : IMeetingWorkflowEngine
{
public Task RunAsync(
@@ -3003,12 +3165,14 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class BlockingMeetingScreenshotService : IMeetingScreenshotService
private sealed class BlockingMeetingScreenshotService : IMeetingScreenshotService, IMeetingNoteImageOcrService
{
private readonly TaskCompletionSource waitStarted =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource releaseWait =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource meetingNoteImageProcessingStarted =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<MeetingScreenshotCaptureResult> CaptureAsync(
MeetingSessionArtifacts artifacts,
@@ -3041,6 +3205,20 @@ public sealed class RecordingCoordinatorTests
return Task.CompletedTask;
}
public Task<MeetingNoteImageOcrQueueResult> ProcessMeetingNoteImageEmbedsAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
meetingNoteImageProcessingStarted.TrySetResult();
return Task.FromResult(new MeetingNoteImageOcrQueueResult(0));
}
public Task WaitUntilMeetingNoteImageProcessingStartedAsync()
{
return meetingNoteImageProcessingStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public Task WaitUntilOcrWaitStartedAsync()
{
return waitStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
@@ -3683,9 +3861,9 @@ public sealed class RecordingCoordinatorTests
return Task.FromResult<IReadOnlyList<OfflineTranscriptionBacklogItem>>(Items.ToList());
}
public Task CompleteAsync(string id, CancellationToken cancellationToken)
public Task CompleteAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken)
{
Items.RemoveAll(item => string.Equals(item.Id, id, StringComparison.Ordinal));
Items.RemoveAll(existing => string.Equals(existing.Id, item.Id, StringComparison.Ordinal));
return Task.CompletedTask;
}
}
@@ -9,5 +9,6 @@ public sealed class ScreenshotOcrOptionsTests
{
Assert.Contains("exactly who is in the meeting", ScreenshotOcrOptions.DefaultPrompt);
Assert.Contains("partial", ScreenshotOcrOptions.DefaultPrompt);
Assert.Contains("\"attendees\"", ScreenshotOcrOptions.DefaultPrompt);
}
}
+29 -1
View File
@@ -31,6 +31,34 @@ public sealed class TaskbarIconTests
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.AbortRecording);
}
[Fact]
public void MenuOffersMicrophoneSubmenuWithEffectiveDeviceChecked()
{
var menu = MeetingTaskbarMenuBuilder.Build(
Status(),
[Profile("default")],
[
new MicrophoneDevice("integrated", "integrated microphone"),
new MicrophoneDevice("other", "other microphone")
],
"integrated");
var microphoneMenu = Assert.Single(menu.Items, item => item.Text == "Microphone");
Assert.Equal(MeetingTaskbarAction.OpenSubmenu, microphoneMenu.Action);
Assert.NotNull(microphoneMenu.Items);
Assert.Contains(microphoneMenu.Items, item =>
item.Action == MeetingTaskbarAction.SelectMicrophone &&
item.MicrophoneDeviceId == "integrated" &&
item.Text == "integrated microphone" &&
item.IsChecked);
Assert.Contains(microphoneMenu.Items, item =>
item.Action == MeetingTaskbarAction.SelectMicrophone &&
item.MicrophoneDeviceId == "other" &&
item.Text == "other microphone" &&
!item.IsChecked);
}
[Fact]
public void RecordingMenuOffersStopAbortAndOtherProfileSwitches()
{
@@ -108,7 +136,7 @@ public sealed class TaskbarIconTests
{
Assert.Equal(
requiresConfirmation,
MeetingTaskbarExitPolicy.RequiresConfirmation(Status(state: state)));
MeetingTaskbarExitPolicy.RequiresConfirmation(state));
}
[Fact]
@@ -442,7 +442,8 @@ public sealed class WorkflowRulesEditorTests
Assert.Equal("summary body", await File.ReadAllTextAsync(Path.Combine(summariesRoot, "daily-summary.md")));
Assert.Equal("one\ntwo\nthree", await File.ReadAllTextAsync(Path.Combine(transcriptsRoot, "daily-transcript.md")));
var note = await File.ReadAllTextAsync(Path.Combine(notesRoot, "daily.md"));
var note = (await File.ReadAllTextAsync(Path.Combine(notesRoot, "daily.md")))
.Replace("\r\n", "\n", StringComparison.Ordinal);
Assert.Contains("title: Fixed", note);
Assert.Contains("projects:\n- Alpha", note);
Assert.Contains("Existing body.", note);
@@ -721,6 +722,16 @@ public sealed class WorkflowRulesEditorTests
Assert.Contains("read_logs", instructions);
Assert.Contains("read_spec_file", instructions);
Assert.Contains("list_recent_summaries", instructions);
}
[Fact]
public async Task InstructionBuilderIncludesProjectSyncGuidanceForCorrectedMeetingNotes()
{
var builder = new WorkflowRulesEditorInstructionBuilder(
NullLogger<WorkflowRulesEditorInstructionBuilder>.Instance);
var instructions = await builder.BuildAsync(new MeetingAssistantOptions(), CancellationToken.None);
Assert.Contains("When correcting a meeting note that has project references", instructions);
Assert.Contains("read that project's AGENTS.md", instructions);
Assert.Contains("new project reference", instructions);