Archive meeting workflow and screenshot changes

This commit is contained in:
2026-05-27 12:55:19 +02:00
parent 2422236ef7
commit 12832dde84
48 changed files with 3041 additions and 43 deletions
@@ -61,6 +61,56 @@ public sealed class LaunchProfileOptionsProviderTests
Assert.Contains("Ctrl+Alt+M", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ScreenshotHotkeysAreRegisteredForDefaultAndExplicitProfileOverrides()
{
var provider = CreateProvider(new Dictionary<string, string?>
{
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
["MeetingAssistant:Hotkey:Abort"] = "Ctrl+Alt+D",
["MeetingAssistant:Screenshots:Hotkey"] = "Ctrl+Alt+S",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Abort"] = "Ctrl+Alt+Shift+D",
["MeetingAssistant:LaunchProfiles:english:Screenshots:Hotkey"] = "Ctrl+Alt+Shift+S"
});
var hotkeys = provider.GetHotkeys();
Assert.Contains(hotkeys, hotkey =>
hotkey.ProfileName == "default" &&
hotkey.Hotkey == "Ctrl+Alt+S" &&
hotkey.Action == LaunchProfileHotkeyAction.CaptureScreenshot);
Assert.Contains(hotkeys, hotkey =>
hotkey.ProfileName == "english" &&
hotkey.Hotkey == "Ctrl+Alt+Shift+S" &&
hotkey.Action == LaunchProfileHotkeyAction.CaptureScreenshot);
Assert.Contains(hotkeys, hotkey =>
hotkey.ProfileName == "default" &&
hotkey.Hotkey == "Ctrl+Alt+D" &&
hotkey.Action == LaunchProfileHotkeyAction.AbortRecording);
Assert.Contains(hotkeys, hotkey =>
hotkey.ProfileName == "english" &&
hotkey.Hotkey == "Ctrl+Alt+Shift+D" &&
hotkey.Action == LaunchProfileHotkeyAction.AbortRecording);
}
[Fact]
public void NamedProfilesDoNotInheritDefaultHotkeysForRegistration()
{
var provider = CreateProvider(new Dictionary<string, string?>
{
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
["MeetingAssistant:Hotkey:Abort"] = "Ctrl+Alt+D",
["MeetingAssistant:Screenshots:Hotkey"] = "Ctrl+Alt+S",
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:Language"] = "en-US"
});
var hotkeys = provider.GetHotkeys();
Assert.Equal(3, hotkeys.Count);
Assert.DoesNotContain(hotkeys, hotkey => hotkey.ProfileName == "english");
}
private static ConfigurationLaunchProfileOptionsProvider CreateProvider(
IReadOnlyDictionary<string, string?> values)
{
@@ -0,0 +1,198 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Drawing;
using System.Drawing.Imaging;
using MeetingAssistant.Screenshots;
using Microsoft.Extensions.Logging.Abstractions;
#pragma warning disable CA1416
namespace MeetingAssistant.Tests;
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 handler = new RecordingHandler("""
{
"output": [
{
"content": [
{ "type": "output_text", "text": "Visible slide text" }
]
}
]
}
""");
var client = new LiteLlmScreenshotOcrClient(
() => handler,
NullLogger<LiteLlmScreenshotOcrClient>.Instance);
var options = new MeetingAssistantOptions
{
Agent =
{
Endpoint = "https://summary.local",
Model = "summary-model",
Key = "agent-key"
},
Screenshots =
{
Ocr =
{
Endpoint = "",
Model = "",
Key = "ocr-key"
}
}
};
var result = await client.ExtractAsync(
screenshotPath,
"Extract screenshot.",
options,
CancellationToken.None);
Assert.Equal("Visible slide text", result.Text);
Assert.Null(result.Crop);
Assert.Equal(new Uri("https://summary.local/v1/responses"), handler.RequestUri);
Assert.Equal("Bearer", handler.Authorization?.Scheme);
Assert.Equal("ocr-key", handler.Authorization?.Parameter);
using var payload = JsonDocument.Parse(handler.RequestBody!);
Assert.Equal("summary-model", payload.RootElement.GetProperty("model").GetString());
var content = payload.RootElement
.GetProperty("input")[0]
.GetProperty("content");
Assert.Equal("Extract screenshot.", content[0].GetProperty("text").GetString());
Assert.Equal("data:image/png;base64,AQID", content[1].GetProperty("image_url").GetString());
}
[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 handler = new RecordingHandler("""{ "output_text": "OCR result" }""");
var client = new LiteLlmScreenshotOcrClient(
() => handler,
NullLogger<LiteLlmScreenshotOcrClient>.Instance);
var options = new MeetingAssistantOptions
{
Agent =
{
Endpoint = "https://summary.local",
Model = "summary-model",
Key = "agent-key"
},
Screenshots =
{
Ocr =
{
Endpoint = "https://vision.local/v1",
Model = "vision-model",
Key = "ocr-key"
}
}
};
var result = await client.ExtractAsync(
screenshotPath,
"Extract screenshot.",
options,
CancellationToken.None);
Assert.Equal("OCR result", result.Text);
Assert.Equal(new Uri("https://vision.local/v1/responses"), handler.RequestUri);
using var payload = JsonDocument.Parse(handler.RequestBody!);
Assert.Equal("vision-model", payload.RootElement.GetProperty("model").GetString());
}
[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 handler = new RecordingHandler("""
{
"output_text": "Slide text\n\n```json\n{ \"crop\": { \"x\": 1, \"y\": 2, \"width\": 3, \"height\": 4 } }\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);
using var payload = JsonDocument.Parse(handler.RequestBody!);
Assert.Contains(
"Original screenshot dimensions: 8x6 pixels.",
payload.RootElement.GetProperty("input")[0].GetProperty("content")[0].GetProperty("text").GetString(),
StringComparison.Ordinal);
}
private sealed class RecordingHandler : HttpMessageHandler
{
private readonly string responseBody;
public RecordingHandler(string responseBody)
{
this.responseBody = responseBody;
}
public Uri? RequestUri { get; private set; }
public AuthenticationHeaderValue? Authorization { get; private set; }
public string? RequestBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestUri = request.RequestUri;
Authorization = request.Headers.Authorization;
RequestBody = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
};
}
}
private static byte[] CreatePngBytes(int width, int height)
{
using var bitmap = new Bitmap(width, height);
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.Clear(Color.White);
}
using var stream = new MemoryStream();
bitmap.Save(stream, ImageFormat.Png);
return stream.ToArray();
}
}
#pragma warning restore CA1416
@@ -0,0 +1,65 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Recording;
namespace MeetingAssistant.Tests;
public sealed class MeetingRunArtifactCleanerTests
{
[Fact]
public async Task DeleteRunArtifactsDeletesGeneratedScreenshotAttachmentsOnly()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var artifacts = new MeetingSessionArtifacts(
Path.Combine(root, "Notes", "meeting.md"),
Path.Combine(root, "Transcripts", "transcript.md"),
Path.Combine(root, "Context", "context.md"),
Path.Combine(root, "Summaries", "summary.md"));
var attachments = Path.Combine(Path.GetDirectoryName(artifacts.AssistantContextPath)!, "Attachments");
Directory.CreateDirectory(attachments);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.TranscriptPath)!);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
var generatedScreenshot = Path.Combine(attachments, "20260527-093135-123-000010.png");
var generatedCrop = Path.Combine(attachments, "20260527-093135-123-000010-cropped.png");
var manualAttachment = Path.Combine(attachments, "manual-reference.png");
var nonImageLinkedFile = Path.Combine(attachments, "20260527-093135-123-000011.png");
foreach (var file in new[]
{
artifacts.MeetingNotePath,
artifacts.TranscriptPath,
artifacts.SummaryPath,
generatedScreenshot,
generatedCrop,
manualAttachment,
nonImageLinkedFile
})
{
await File.WriteAllTextAsync(file, "content");
}
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
"""
![Screenshot](Attachments/20260527-093135-123-000010.png)
![Cropped screenshot](Attachments/20260527-093135-123-000010-cropped.png)
![Manual reference](Attachments/manual-reference.png)
[Reference](Attachments/20260527-093135-123-000011.png)
""");
var cleaner = new MeetingRunArtifactCleaner();
await cleaner.DeleteRunArtifactsAsync(
artifacts,
new MeetingAssistantOptions(),
CancellationToken.None);
Assert.False(File.Exists(artifacts.MeetingNotePath));
Assert.False(File.Exists(artifacts.TranscriptPath));
Assert.False(File.Exists(artifacts.AssistantContextPath));
Assert.False(File.Exists(artifacts.SummaryPath));
Assert.False(File.Exists(generatedScreenshot));
Assert.False(File.Exists(generatedCrop));
Assert.True(File.Exists(manualAttachment));
Assert.True(File.Exists(nonImageLinkedFile));
}
}
@@ -0,0 +1,341 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Screenshots;
using Microsoft.Extensions.Logging.Abstractions;
using System.Drawing;
using System.Drawing.Imaging;
#pragma warning disable CA1416
namespace MeetingAssistant.Tests;
public sealed class MeetingScreenshotServiceTests
{
[Fact]
public async Task CaptureSavesScreenshotAndAppendsTimestampedLink()
{
var fixture = await ScreenshotFixture.CreateAsync();
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
new CapturingScreenshotOcrClient());
var result = await service.CaptureAsync(
fixture.Artifacts,
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
DateTimeOffset.Parse("2026-05-26T10:03:05+02:00"),
fixture.Options,
CancellationToken.None);
Assert.True(File.Exists(result.ScreenshotPath));
Assert.Equal([1, 2, 3], await File.ReadAllBytesAsync(result.ScreenshotPath));
Assert.StartsWith(
Path.Combine(Path.GetDirectoryName(fixture.Artifacts.AssistantContextPath)!, "Attachments"),
result.ScreenshotPath,
StringComparison.OrdinalIgnoreCase);
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
Assert.Contains("## Screenshot [00:03:05]", context);
Assert.Contains("![Screenshot 00:03:05](Attachments/", context);
}
[Fact]
public async Task CaptureSkipsOcrWhenOcrIsDisabled()
{
var fixture = await ScreenshotFixture.CreateAsync();
var ocr = new CapturingScreenshotOcrClient();
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
ocr);
await service.CaptureAsync(
fixture.Artifacts,
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
DateTimeOffset.Parse("2026-05-26T10:00:05+02:00"),
fixture.Options,
CancellationToken.None);
Assert.Equal(0, ocr.CallCount);
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
Assert.DoesNotContain("OCR", context);
}
[Fact]
public async Task CaptureAppendsOcrResultAfterScreenshotWhenConfigured()
{
var fixture = await ScreenshotFixture.CreateAsync(options =>
{
options.Screenshots.Ocr.Enabled = true;
options.Screenshots.Ocr.Prompt = "Extract this meeting screenshot.";
});
var ocr = new CapturingScreenshotOcrClient("Slide text\n\n```mermaid\ngraph TD\nA-->B\n```");
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
ocr);
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);
Assert.Equal(1, ocr.CallCount);
Assert.Equal("Extract this meeting screenshot.", ocr.Prompt);
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
var imageIndex = context.IndexOf("![Screenshot 00:00:10]", StringComparison.Ordinal);
var ocrIndex = context.IndexOf("### OCR", StringComparison.Ordinal);
Assert.True(imageIndex >= 0);
Assert.True(ocrIndex > imageIndex);
Assert.Contains("Slide text", context);
Assert.Contains("```mermaid", context);
}
[Fact]
public async Task CaptureSavesCroppedImageAndLinksItBeforeOcrWhenOcrReturnsCropCoordinates()
{
var fixture = await ScreenshotFixture.CreateAsync(options =>
{
options.Screenshots.Ocr.Enabled = true;
});
var ocr = new CapturingScreenshotOcrClient(
"Shared screen text",
new ScreenshotCropCoordinates(1, 1, 2, 2));
var service = fixture.CreateService(
new FixedScreenshotCapture(CreatePngBytes(4, 4)),
ocr);
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 croppedPath = Assert.Single(Directory.GetFiles(
Path.Combine(Path.GetDirectoryName(fixture.Artifacts.AssistantContextPath)!, "Attachments"),
"*-cropped.png"));
using (var cropped = new Bitmap(croppedPath))
{
Assert.Equal(2, cropped.Width);
Assert.Equal(2, cropped.Height);
}
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
var screenshotIndex = context.IndexOf("![Screenshot 00:00:10]", StringComparison.Ordinal);
var cropIndex = context.IndexOf("![Cropped screenshot](Attachments/", StringComparison.Ordinal);
var ocrIndex = context.IndexOf("### OCR", StringComparison.Ordinal);
Assert.True(screenshotIndex >= 0);
Assert.True(cropIndex > screenshotIndex);
Assert.True(ocrIndex > cropIndex);
Assert.Contains("Shared screen text", context);
}
[Fact]
public async Task WaitForPendingOcrWaitsForRunningScreenshotOcr()
{
var fixture = await ScreenshotFixture.CreateAsync(options =>
{
options.Screenshots.Ocr.Enabled = true;
});
var ocr = new BlockingScreenshotOcrClient();
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
ocr);
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 ocr.WaitUntilStartedAsync();
var wait = service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
Assert.False(wait.IsCompleted);
ocr.Release("OCR complete.");
await wait;
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
Assert.Contains("OCR complete.", context);
}
[Fact]
public async Task CancelPendingOcrCancelsRunningOcrWithoutWritingFailureText()
{
var fixture = await ScreenshotFixture.CreateAsync(options =>
{
options.Screenshots.Ocr.Enabled = true;
});
var ocr = new BlockingScreenshotOcrClient();
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
ocr);
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 ocr.WaitUntilStartedAsync();
await service.CancelPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
Assert.Contains("_OCR pending..._", context);
Assert.DoesNotContain("OCR timed out", context);
Assert.DoesNotContain("OCR failed", context);
}
private sealed class ScreenshotFixture
{
private ScreenshotFixture(
MeetingAssistantOptions options,
MeetingSessionArtifacts artifacts,
MarkdownMeetingArtifactStore artifactStore)
{
Options = options;
Artifacts = artifacts;
ArtifactStore = artifactStore;
}
public MeetingAssistantOptions Options { get; }
public MeetingSessionArtifacts Artifacts { get; }
public MarkdownMeetingArtifactStore ArtifactStore { get; }
public static async Task<ScreenshotFixture> CreateAsync(
Action<MeetingAssistantOptions>? configure = null)
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var options = new MeetingAssistantOptions
{
Vault = new VaultOptions
{
BaseFolder = root,
AssistantContextFolder = "Context"
}
};
configure?.Invoke(options);
var artifacts = new MeetingSessionArtifacts(
Path.Combine(root, "Notes", "meeting.md"),
Path.Combine(root, "Transcripts", "transcript.md"),
Path.Combine(root, "Context", "context.md"),
Path.Combine(root, "Summaries", "summary.md"));
var artifactStore = new MarkdownMeetingArtifactStore(
NullLogger<MarkdownMeetingArtifactStore>.Instance);
var meeting = MeetingNoteTemplate.Create(
"Planning",
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
transcriptPath: artifacts.TranscriptPath,
assistantContextPath: artifacts.AssistantContextPath,
summaryPath: artifacts.SummaryPath) with
{
Path = artifacts.MeetingNotePath
};
await artifactStore.CreateAssistantContextAsync(
artifacts,
meeting,
"",
null,
CancellationToken.None);
return new ScreenshotFixture(options, artifacts, artifactStore);
}
public MeetingScreenshotService CreateService(
IActiveWindowScreenshotCapture capture,
IScreenshotOcrClient ocrClient)
{
return new MeetingScreenshotService(
capture,
ArtifactStore,
ocrClient,
NullLogger<MeetingScreenshotService>.Instance);
}
}
private sealed class FixedScreenshotCapture : IActiveWindowScreenshotCapture
{
private readonly byte[] bytes;
public FixedScreenshotCapture(byte[] bytes)
{
this.bytes = bytes;
}
public Task<ActiveWindowScreenshot> CaptureAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new ActiveWindowScreenshot(bytes, "Demo Window"));
}
}
private sealed class CapturingScreenshotOcrClient : IScreenshotOcrClient
{
private readonly ScreenshotOcrResult result;
public CapturingScreenshotOcrClient(
string text = "",
ScreenshotCropCoordinates? crop = null)
{
result = new ScreenshotOcrResult(text, crop);
}
public int CallCount { get; private set; }
public string? Prompt { get; private set; }
public Task<ScreenshotOcrResult> ExtractAsync(
string screenshotPath,
string prompt,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
CallCount++;
Prompt = prompt;
return Task.FromResult(result);
}
}
private sealed class BlockingScreenshotOcrClient : IScreenshotOcrClient
{
private readonly TaskCompletionSource started = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource<ScreenshotOcrResult> result = new(TaskCreationOptions.RunContinuationsAsynchronously);
public async Task<ScreenshotOcrResult> ExtractAsync(
string screenshotPath,
string prompt,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
started.TrySetResult();
await using var registration = cancellationToken.Register(() => result.TrySetCanceled(cancellationToken));
return await result.Task;
}
public Task WaitUntilStartedAsync()
{
return started.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public void Release(string value)
{
result.TrySetResult(new ScreenshotOcrResult(value, null));
}
}
private static byte[] CreatePngBytes(int width, int height)
{
using var bitmap = new Bitmap(width, height);
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.Clear(Color.White);
}
using var stream = new MemoryStream();
bitmap.Save(stream, ImageFormat.Png);
return stream.ToArray();
}
}
#pragma warning restore CA1416
@@ -22,6 +22,8 @@ public sealed class MeetingSummaryInstructionBuilderTests
var instructions = await builder.BuildAsync(artifacts, CancellationToken.None);
Assert.Contains("You are the Meeting Assistant summary agent.", instructions);
Assert.Contains("include only the most relevant cropped screenshots", instructions);
Assert.Contains("Do not include every cropped screenshot", instructions);
}
[Fact]
@@ -191,6 +191,48 @@ public sealed class MeetingSummaryToolTests
Assert.Contains("replacement\ninserted\nline two", context);
}
[Fact]
public async Task ToolsWriteAssistantContextStripsAccidentalFrontmatterFromReplacementBody()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var artifacts = new MeetingSessionArtifacts(
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
"""
---
meeting: "[[../Notes/meeting|Meeting Note]]"
transcript: "[[../Transcripts/transcript|Transcript]]"
summary: "[[../Summaries/summary|Summary]]"
state: summarizing
---
line one
""");
var tools = new MeetingSummaryTools(artifacts);
await tools.WriteContext(
"""
---
state: finished
---
# Assistant Context
## Live Context
""");
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
Assert.Contains("state: summarizing", context);
Assert.DoesNotContain("state: finished", context);
Assert.Equal(2, context.Split("---", StringSplitOptions.None).Length - 1);
Assert.Contains("# Assistant Context", context);
}
[Fact]
public async Task ExternalAgentWritesAreAppendedToAssistantContextAsDiffs()
{
@@ -74,6 +74,85 @@ public sealed class MeetingWorkflowEngineTests
Assert.Equal(["Architecture"], meeting.Frontmatter.Projects);
}
[Fact]
public async Task StepValuesWithEmailAddressesAreTreatedAsLiteralText()
{
var fixture = await WorkflowFixture.CreateAsync(
"""
rules:
- name: normalize-sew-sofi-daily
on:
- state_transition:
from: collecting metadata
to: transcribing
if:
- condition: "meeting.title = 'WG: Sofi - Daily - Team Velocity Vanguards'"
steps:
- uses: set_property
property: title
value: 'SEW Sofi - Daily'
- uses: add_project
value: 'SEW Solution Finder'
- uses: add_attendee
value: 'niklas.link.e@sew-eurodrive.de'
""",
title: "WG: Sofi - Daily - Team Velocity Vanguards");
await fixture.Engine.RunAsync(
MeetingWorkflowEvent.StateTransition(
fixture.Artifacts,
AssistantContextState.CollectingMetadata,
AssistantContextState.Transcribing),
fixture.Options,
CancellationToken.None);
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
Assert.Equal("SEW Sofi - Daily", meeting.Frontmatter.Title);
Assert.Equal(["SEW Solution Finder"], meeting.Frontmatter.Projects);
Assert.Equal(["niklas.link.e@sew-eurodrive.de"], meeting.Frontmatter.Attendees);
}
[Fact]
public async Task StepValuesCanMixEmailAddressesAndRazorTemplates()
{
var fixture = await WorkflowFixture.CreateAsync(
"""
rules:
- name: mixed-template
on:
- created: {}
steps:
- uses: add_context
value: 'Contact Support@contoso.com about @Model.Meeting.Title.'
""",
title: "Architecture Sync");
await RunCreatedAsync(fixture);
var context = await fixture.ReadContextAsync();
Assert.Contains("Contact Support@contoso.com about Architecture Sync.", context);
}
[Fact]
public async Task StepValuesRenderRazorTransitionsThatAreNotModelMarkers()
{
var fixture = await WorkflowFixture.CreateAsync(
"""
rules:
- name: razor-transition
on:
- created: {}
steps:
- uses: add_context
value: 'Generated in @System.DateTime.UtcNow.Year'
""");
await RunCreatedAsync(fixture);
var context = await fixture.ReadContextAsync();
Assert.Contains($"Generated in {DateTime.UtcNow.Year}", context);
}
[Fact]
public async Task SpeakerIdentifiedRuleFiltersByNameAndWritesRazorContext()
{
@@ -1,6 +1,7 @@
using MeetingAssistant.Recording;
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Speakers;
using MeetingAssistant.Screenshots;
using MeetingAssistant.Transcription;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary;
@@ -30,6 +31,36 @@ public sealed class RecordingCoordinatorTests
throw new TimeoutException("Condition was not met.");
}
private static async Task AppendTextWithRetryAsync(string path, string text)
{
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (true)
{
try
{
await File.AppendAllTextAsync(path, text);
return;
}
catch (IOException) when (DateTimeOffset.UtcNow < deadline)
{
await Task.Delay(25);
}
}
}
private static bool FileContainsText(string path, string text)
{
try
{
return File.Exists(path) &&
File.ReadAllText(path).Contains(text, StringComparison.Ordinal);
}
catch (IOException)
{
return false;
}
}
[Fact]
public async Task ToggleStartsStreamingTranscriptionAndSecondToggleStopsIt()
{
@@ -183,6 +214,47 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartSkipsOutlookMeetingAttendeesAboveConfiguredImportLimit()
{
var audioSource = new ControlledAudioSource();
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\metadata-meeting.md");
var artifactStore = new InMemoryMeetingArtifactStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
noteStore,
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions
{
Recording =
{
MaxMetadataAttendeeImportCount = 2
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
new FixedMeetingMetadataProvider(new MeetingMetadata(
"Presentation Review",
["Ada", "Grace", "Linus"],
"Large audience agenda",
DateTimeOffset.Parse("2026-05-19T14:00:00+02:00"))));
await coordinator.StartAsync(CancellationToken.None);
await WaitUntilAsync(() => artifactStore.Agenda == "Large audience agenda");
Assert.Equal("Presentation Review", noteStore.SavedNote?.Frontmatter.Title);
Assert.Empty(noteStore.SavedNote?.Frontmatter.Attendees ?? []);
Assert.Equal("Presentation Review", artifactStore.ContextMeetingNote?.Frontmatter.Title);
Assert.Equal("Large audience agenda", artifactStore.Agenda);
Assert.Equal(DateTimeOffset.Parse("2026-05-19T14:00:00+02:00"), artifactStore.ScheduledEnd);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartDoesNotWaitForSlowMeetingMetadataButAppliesItLater()
{
@@ -364,6 +436,94 @@ public sealed class RecordingCoordinatorTests
await store.WaitForTextAsync("final:2");
}
[Fact]
public async Task AbortStopsRecordingDeletesArtifactsAndSkipsSummary()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var options = new MeetingAssistantOptions
{
Vault = new VaultOptions
{
BaseFolder = root,
TranscriptsFolder = "Transcripts",
MeetingNotesFolder = "Notes",
AssistantContextFolder = "Context",
SummariesFolder = "Summaries"
},
Recording = new RecordingOptions
{
StopProcessingTimeout = TimeSpan.FromSeconds(5)
}
};
var audioSource = new ControlledAudioSource();
var summaryPipeline = new CapturingMeetingSummaryPipeline();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new VaultTranscriptStore(Options.Create(options), NullLogger<VaultTranscriptStore>.Instance),
new MarkdownMeetingNoteStore(Options.Create(options), NullLogger<MarkdownMeetingNoteStore>.Instance),
new CapturingMeetingNoteOpener(),
new MarkdownMeetingArtifactStore(NullLogger<MarkdownMeetingArtifactStore>.Instance),
new InMemoryRecordedAudioStore(),
summaryPipeline,
Options.Create(options),
NullLogger<MeetingRecordingCoordinator>.Instance);
var started = await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await WaitUntilAsync(() =>
FileContainsText(started.AssistantContextPath!, "state: transcribing"));
var attachmentPath = Path.Combine(
Path.GetDirectoryName(started.AssistantContextPath!)!,
"Attachments",
"20260527-093135-123-000010-cropped.png");
Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!);
await File.WriteAllTextAsync(attachmentPath, "image");
await AppendTextWithRetryAsync(
started.AssistantContextPath!,
Environment.NewLine + "![Cropped screenshot](Attachments/20260527-093135-123-000010-cropped.png)" + Environment.NewLine);
Directory.CreateDirectory(Path.GetDirectoryName(started.SummaryPath!)!);
await File.WriteAllTextAsync(started.SummaryPath!, "draft summary");
var aborted = await coordinator.AbortAsync(CancellationToken.None);
Assert.False(aborted.IsRecording);
Assert.Null(aborted.MeetingNotePath);
Assert.False(File.Exists(started.MeetingNotePath));
Assert.False(File.Exists(started.TranscriptPath));
Assert.False(File.Exists(started.AssistantContextPath));
Assert.False(File.Exists(started.SummaryPath));
Assert.False(File.Exists(attachmentPath));
Assert.Null(summaryPipeline.Artifacts);
await Task.Delay(250);
Assert.False(File.Exists(started.AssistantContextPath));
}
[Fact]
public async Task AbortDoesNothingWhenRecordingIsIdle()
{
var coordinator = new MeetingRecordingCoordinator(
new ControlledAudioSource(),
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance);
var status = await coordinator.AbortAsync(CancellationToken.None);
Assert.False(status.IsRecording);
Assert.Null(status.MeetingNotePath);
Assert.Null(status.TranscriptPath);
Assert.Null(status.AssistantContextPath);
Assert.Null(status.SummaryPath);
}
[Fact]
public async Task CaptureStartsEvenWhenTranscriptionProviderIsStillWarmingUp()
{
@@ -950,6 +1110,98 @@ public sealed class RecordingCoordinatorTests
artifactStore.States);
}
[Fact]
public async Task CaptureScreenshotUsesCurrentMeetingArtifactsAndStartTime()
{
var audioSource = new ControlledAudioSource();
var screenshotService = new CapturingMeetingScreenshotService();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(CreateOptionsWithoutFinalizer()),
NullLogger<MeetingRecordingCoordinator>.Instance,
screenshotService: screenshotService);
var started = await coordinator.StartAsync(CancellationToken.None);
var result = await coordinator.CaptureScreenshotAsync(CancellationToken.None);
Assert.Equal(started.AssistantContextPath, screenshotService.Artifacts?.AssistantContextPath);
Assert.NotNull(screenshotService.MeetingStartedAt);
Assert.Equal("memory-screenshot.png", result.ScreenshotPath);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task CaptureScreenshotFailsAfterRecordingStopHasStarted()
{
var audioSource = new ControlledAudioSource();
var screenshotService = new CapturingMeetingScreenshotService();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(CreateOptionsWithoutFinalizer()),
NullLogger<MeetingRecordingCoordinator>.Instance,
screenshotService: screenshotService);
await coordinator.StartAsync(CancellationToken.None);
var stop = coordinator.StopAsync(CancellationToken.None);
await Assert.ThrowsAsync<InvalidOperationException>(
() => coordinator.CaptureScreenshotAsync(CancellationToken.None));
audioSource.Complete();
await stop;
}
[Fact]
public async Task StopWaitsForPendingScreenshotOcrBeforeSummarizing()
{
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);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WaitUntilCapturedAsync();
var stop = coordinator.StopAsync(CancellationToken.None);
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()
{
@@ -1396,6 +1648,93 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class CapturingMeetingScreenshotService : IMeetingScreenshotService
{
public MeetingSessionArtifacts? Artifacts { get; private set; }
public DateTimeOffset? MeetingStartedAt { get; private set; }
public Task<MeetingScreenshotCaptureResult> CaptureAsync(
MeetingSessionArtifacts artifacts,
DateTimeOffset? meetingStartedAt,
DateTimeOffset capturedAt,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
Artifacts = artifacts;
MeetingStartedAt = meetingStartedAt;
return Task.FromResult(new MeetingScreenshotCaptureResult(
"memory-screenshot.png",
TimeSpan.FromSeconds(1),
OcrStarted: false));
}
public Task WaitForPendingOcrAsync(
MeetingSessionArtifacts artifacts,
TimeSpan timeout,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task CancelPendingOcrAsync(
MeetingSessionArtifacts artifacts,
TimeSpan timeout,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
private sealed class BlockingMeetingScreenshotService : IMeetingScreenshotService
{
private readonly TaskCompletionSource waitStarted =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource releaseWait =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<MeetingScreenshotCaptureResult> CaptureAsync(
MeetingSessionArtifacts artifacts,
DateTimeOffset? meetingStartedAt,
DateTimeOffset capturedAt,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return Task.FromResult(new MeetingScreenshotCaptureResult(
"memory-screenshot.png",
TimeSpan.Zero,
OcrStarted: true));
}
public async Task WaitForPendingOcrAsync(
MeetingSessionArtifacts artifacts,
TimeSpan timeout,
CancellationToken cancellationToken)
{
waitStarted.TrySetResult();
await releaseWait.Task.WaitAsync(cancellationToken);
}
public Task CancelPendingOcrAsync(
MeetingSessionArtifacts artifacts,
TimeSpan timeout,
CancellationToken cancellationToken)
{
releaseWait.TrySetResult();
return Task.CompletedTask;
}
public Task WaitUntilOcrWaitStartedAsync()
{
return waitStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public void ReleaseOcrWait()
{
releaseWait.TrySetResult();
}
}
private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore
{
public MeetingSessionArtifacts? CreatedArtifacts { get; private set; }
@@ -2140,6 +2479,11 @@ public sealed class RecordingCoordinatorTests
{
return chunks.Writer.WriteAsync(chunk, cancellationToken);
}
public void Complete()
{
chunks.Writer.TryComplete();
}
}
private sealed class CapturedChunkThenCancelAudioSource : IMeetingAudioSource