1 Commits
Author SHA1 Message Date
renovate-bot 1afc1e14f9 Update dependency System.Drawing.Common to 10.0.9
PR and Push Build/Test / build-and-test (pull_request) Failing after 8m58s
PR and Push Build/Test / build-and-test (push) Successful in 8m50s
2026-06-11 02:30:56 +00:00
12 changed files with 86 additions and 201 deletions
@@ -105,36 +105,6 @@ public sealed class MeetingNoteStoreTests
Assert.Equal(userNotes, reloaded.UserNotes); Assert.Equal(userNotes, reloaded.UserNotes);
} }
[Fact]
public async Task ReadConvertsScalarListFrontmatterToSingleItemLists()
{
var (vaultRoot, store) = CreateStore();
var notePath = Path.Combine(vaultRoot, "Meetings", "Notes", "manual.md");
Directory.CreateDirectory(Path.GetDirectoryName(notePath)!);
await File.WriteAllTextAsync(
notePath,
"""
---
title: Manual frontmatter
start_time: "2026-05-19T10:00:00.0000000+02:00"
end_time: ""
attendees: Ada
projects: Meeting Assistant
transcript: "[[../Transcripts/manual-transcript|Transcript]]"
assistant_context: "[[../Assistant Context/manual-context|Assistant Context]]"
summary: "[[../Summaries/manual-summary|Summary]]"
---
User notes.
""");
var loaded = await store.ReadAsync(notePath, CancellationToken.None);
Assert.Equal(["Ada"], loaded.Frontmatter.Attendees);
Assert.Equal(["Meeting Assistant"], loaded.Frontmatter.Projects);
Assert.Equal("User notes.", loaded.UserNotes);
}
[Fact] [Fact]
public void ActionLinkEscapesSummaryFileName() public void ActionLinkEscapesSummaryFileName()
{ {
@@ -63,34 +63,6 @@ public sealed class ProjectKnowledgeToolTests
await tools.Search("alpha")); await tools.Search("alpha"));
} }
[Fact]
public async Task ListProjectsAcceptsScalarProjectFrontmatter()
{
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,
"""
---
projects: MeetingAssistant
---
""");
var tools = new MeetingSummaryTools(
artifacts,
new MeetingAssistantOptions
{
Vault =
{
ProjectsFolder = projectsRoot
}
});
Assert.Equal("MeetingAssistant", await tools.ListProjects());
}
[Fact] [Fact]
public async Task WriteProjectFileSupportsAppendReplaceInsertOverwriteAndCreate() public async Task WriteProjectFileSupportsAppendReplaceInsertOverwriteAndCreate()
{ {
@@ -15,7 +15,7 @@ namespace MeetingAssistant.Tests;
public sealed class RecordingCoordinatorTests public sealed class RecordingCoordinatorTests
{ {
private static async Task WaitUntilAsync(Func<bool> condition, string? timeoutMessage = null) private static async Task WaitUntilAsync(Func<bool> condition)
{ {
var deadline = DateTimeOffset.UtcNow.AddSeconds(15); var deadline = DateTimeOffset.UtcNow.AddSeconds(15);
while (DateTimeOffset.UtcNow < deadline) while (DateTimeOffset.UtcNow < deadline)
@@ -28,7 +28,7 @@ public sealed class RecordingCoordinatorTests
await Task.Delay(25); await Task.Delay(25);
} }
throw new TimeoutException(timeoutMessage ?? "Condition was not met."); throw new TimeoutException("Condition was not met.");
} }
private static async Task AppendTextWithRetryAsync(string path, string text) private static async Task AppendTextWithRetryAsync(string path, string text)
@@ -365,7 +365,6 @@ public sealed class RecordingCoordinatorTests
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment => await WaitUntilAsync(() => transcriptStore.Segments.Any(segment =>
segment.Text.Contains("latest transcript text", StringComparison.Ordinal))); segment.Text.Contains("latest transcript text", StringComparison.Ordinal)));
await WaitUntilAsync(() => clock.PendingDelayCount > 0);
clock.Advance(TimeSpan.FromSeconds(2)); clock.Advance(TimeSpan.FromSeconds(2));
await promptService.WaitForPromptAsync(); await promptService.WaitForPromptAsync();
await WaitUntilAsync(() => summaryPipeline.WasRun); await WaitUntilAsync(() => summaryPipeline.WasRun);
@@ -415,10 +414,8 @@ public sealed class RecordingCoordinatorTests
await coordinator.StartAsync(CancellationToken.None); await coordinator.StartAsync(CancellationToken.None);
await WaitUntilAsync(() => clock.PendingDelayCount > 0);
clock.Advance(TimeSpan.FromSeconds(5)); clock.Advance(TimeSpan.FromSeconds(5));
await WaitUntilAsync(() => !coordinator.CurrentStatus.IsRecording); await WaitUntilAsync(() => !coordinator.CurrentStatus.IsRecording);
await WaitUntilAsync(() => summaryPipeline.WasRun);
Assert.Empty(promptService.Requests); Assert.Empty(promptService.Requests);
Assert.False(coordinator.CurrentStatus.IsRecording); Assert.False(coordinator.CurrentStatus.IsRecording);
@@ -462,7 +459,6 @@ public sealed class RecordingCoordinatorTests
inactivityClock: clock); inactivityClock: clock);
await coordinator.StartAsync(CancellationToken.None); await coordinator.StartAsync(CancellationToken.None);
await WaitUntilAsync(() => clock.PendingDelayCount > 0);
clock.Advance(TimeSpan.FromSeconds(2)); clock.Advance(TimeSpan.FromSeconds(2));
await promptService.WaitForPromptAsync(); await promptService.WaitForPromptAsync();
await WaitUntilAsync(() => clock.PendingDelayCount > 0); await WaitUntilAsync(() => clock.PendingDelayCount > 0);
@@ -513,14 +509,12 @@ public sealed class RecordingCoordinatorTests
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment => await WaitUntilAsync(() => transcriptStore.Segments.Any(segment =>
segment.Text.Contains("chunk:2", StringComparison.Ordinal))); segment.Text.Contains("chunk:2", StringComparison.Ordinal)));
await WaitUntilAsync(() => clock.PendingDelayCount > 0);
clock.Advance(TimeSpan.FromSeconds(2)); clock.Advance(TimeSpan.FromSeconds(2));
await WaitUntilAsync(() => promptService.Requests.Count == 1); await WaitUntilAsync(() => promptService.Requests.Count == 1);
await audioSource.WriteAsync(new AudioChunk([1, 0, 2, 0], 16000, 1), CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([1, 0, 2, 0], 16000, 1), CancellationToken.None);
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment => await WaitUntilAsync(() => transcriptStore.Segments.Any(segment =>
segment.Text.Contains("chunk:4", StringComparison.Ordinal))); segment.Text.Contains("chunk:4", StringComparison.Ordinal)));
await WaitUntilAsync(() => clock.PendingDelayCount > 0);
clock.Advance(TimeSpan.FromSeconds(2)); clock.Advance(TimeSpan.FromSeconds(2));
await WaitUntilAsync(() => promptService.Requests.Count == 2); await WaitUntilAsync(() => promptService.Requests.Count == 2);
@@ -843,7 +837,6 @@ public sealed class RecordingCoordinatorTests
}; };
var audioSource = new ControlledAudioSource(); var audioSource = new ControlledAudioSource();
var summaryPipeline = new CapturingMeetingSummaryPipeline(); var summaryPipeline = new CapturingMeetingSummaryPipeline();
var metadataProvider = new BlockingMeetingMetadataProvider(new MeetingMetadata("", [], ""));
var coordinator = new MeetingRecordingCoordinator( var coordinator = new MeetingRecordingCoordinator(
audioSource, audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
@@ -854,44 +847,37 @@ public sealed class RecordingCoordinatorTests
new InMemoryRecordedAudioStore(), new InMemoryRecordedAudioStore(),
summaryPipeline, summaryPipeline,
Options.Create(options), Options.Create(options),
NullLogger<MeetingRecordingCoordinator>.Instance, NullLogger<MeetingRecordingCoordinator>.Instance);
meetingMetadataProvider: metadataProvider);
try var started = await coordinator.StartAsync(CancellationToken.None);
{ await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
var started = await coordinator.StartAsync(CancellationToken.None); await WaitUntilAsync(() =>
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); FileContainsText(started.AssistantContextPath!, "state: transcribing"));
await WaitUntilAsync(() => File.Exists(started.AssistantContextPath!)); var attachmentPath = Path.Combine(
var attachmentPath = Path.Combine( Path.GetDirectoryName(started.AssistantContextPath!)!,
Path.GetDirectoryName(started.AssistantContextPath!)!, "Attachments",
"Attachments", "20260527-093135-123-000010-cropped.png");
"20260527-093135-123-000010-cropped.png"); Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!);
Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!); await File.WriteAllTextAsync(attachmentPath, "image");
await File.WriteAllTextAsync(attachmentPath, "image"); await AppendTextWithRetryAsync(
await AppendTextWithRetryAsync( started.AssistantContextPath!,
started.AssistantContextPath!, Environment.NewLine + "![Cropped screenshot](Attachments/20260527-093135-123-000010-cropped.png)" + Environment.NewLine);
Environment.NewLine + "![Cropped screenshot](Attachments/20260527-093135-123-000010-cropped.png)" + Environment.NewLine); Directory.CreateDirectory(Path.GetDirectoryName(started.SummaryPath!)!);
Directory.CreateDirectory(Path.GetDirectoryName(started.SummaryPath!)!); await File.WriteAllTextAsync(started.SummaryPath!, "draft summary");
await File.WriteAllTextAsync(started.SummaryPath!, "draft summary");
var aborted = await coordinator.AbortAsync(CancellationToken.None); var aborted = await coordinator.AbortAsync(CancellationToken.None);
Assert.False(aborted.IsRecording); Assert.False(aborted.IsRecording);
Assert.Null(aborted.MeetingNotePath); Assert.Null(aborted.MeetingNotePath);
Assert.False(File.Exists(started.MeetingNotePath)); Assert.False(File.Exists(started.MeetingNotePath));
Assert.False(File.Exists(started.TranscriptPath)); Assert.False(File.Exists(started.TranscriptPath));
Assert.False(File.Exists(started.AssistantContextPath)); Assert.False(File.Exists(started.AssistantContextPath));
Assert.False(File.Exists(started.SummaryPath)); Assert.False(File.Exists(started.SummaryPath));
Assert.False(File.Exists(attachmentPath)); Assert.False(File.Exists(attachmentPath));
Assert.Null(summaryPipeline.Artifacts); Assert.Null(summaryPipeline.Artifacts);
await Task.Delay(250); await Task.Delay(250);
Assert.False(File.Exists(started.AssistantContextPath)); Assert.False(File.Exists(started.AssistantContextPath));
}
finally
{
metadataProvider.Release();
}
} }
[Fact] [Fact]
@@ -2181,8 +2167,8 @@ public sealed class RecordingCoordinatorTests
private sealed class InMemoryTranscriptStore : ITranscriptStore private sealed class InMemoryTranscriptStore : ITranscriptStore
{ {
private readonly object gate = new();
private readonly List<TranscriptionSegment> segments = []; private readonly List<TranscriptionSegment> segments = [];
private readonly TaskCompletionSource segmentWritten = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly string transcriptPath; private readonly string transcriptPath;
public InMemoryTranscriptStore(string transcriptPath = "memory-transcript.md") public InMemoryTranscriptStore(string transcriptPath = "memory-transcript.md")
@@ -2205,33 +2191,30 @@ public sealed class RecordingCoordinatorTests
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken) public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
{ {
lock (gate) segments.Add(ParseTranscriptLine(line));
{ segmentWritten.TrySetResult();
segments.Add(ParseTranscriptLine(line));
}
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task WaitForTextAsync(string text) public async Task WaitForTextAsync(string text)
{ {
return WaitUntilAsync( var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
() => Segments.Any(segment => segment?.Text?.Contains(text, StringComparison.Ordinal) == true), while (DateTimeOffset.UtcNow < deadline)
$"Segment containing '{text}' was not written."); {
if (segments.Any(segment => segment?.Text?.Contains(text, StringComparison.Ordinal) == true))
{
return;
}
await segmentWritten.Task.WaitAsync(TimeSpan.FromMilliseconds(100));
}
throw new TimeoutException($"Segment containing '{text}' was not written.");
} }
public IReadOnlyList<TranscriptionSegment> ReplacedSegments { get; private set; } = []; public IReadOnlyList<TranscriptionSegment> ReplacedSegments { get; private set; } = [];
public IReadOnlyList<TranscriptionSegment> Segments public IReadOnlyList<TranscriptionSegment> Segments => segments;
{
get
{
lock (gate)
{
return segments.ToArray();
}
}
}
public MeetingNote? MetadataMeetingNote { get; private set; } public MeetingNote? MetadataMeetingNote { get; private set; }
@@ -2259,6 +2242,7 @@ public sealed class RecordingCoordinatorTests
{ {
private int created; private int created;
private int appendCount; private int appendCount;
private TaskCompletionSource appendObserved = new(TaskCreationOptions.RunContinuationsAsynchronously);
public List<TranscriptWrite> AppendHistory { get; } = []; public List<TranscriptWrite> AppendHistory { get; } = [];
@@ -2284,14 +2268,29 @@ public sealed class RecordingCoordinatorTests
{ {
AppendHistory.Add(new TranscriptWrite(session, ParseTranscriptLine(line))); AppendHistory.Add(new TranscriptWrite(session, ParseTranscriptLine(line)));
Interlocked.Increment(ref appendCount); Interlocked.Increment(ref appendCount);
appendObserved.TrySetResult();
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task WaitForAppendCountAsync(int expectedCount) public async Task WaitForAppendCountAsync(int expectedCount)
{ {
return WaitUntilAsync( var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
() => Volatile.Read(ref appendCount) >= expectedCount, while (DateTimeOffset.UtcNow < deadline)
$"Expected {expectedCount} transcript append(s)."); {
if (Volatile.Read(ref appendCount) >= expectedCount)
{
return;
}
var observed = appendObserved;
await observed.Task.WaitAsync(TimeSpan.FromMilliseconds(100));
if (ReferenceEquals(observed, appendObserved))
{
appendObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
}
}
throw new TimeoutException($"Expected {expectedCount} transcript append(s).");
} }
public Task ReplaceLinesAsync( public Task ReplaceLinesAsync(
+3 -3
View File
@@ -20,16 +20,16 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.10.0" /> <PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.2" />
<PackageReference Include="DiffPlex" Version="1.9.0" /> <PackageReference Include="DiffPlex" Version="1.9.0" />
<PackageReference Include="Microsoft.CognitiveServices.Speech" Version="$(MicrosoftSpeechVersion)" /> <PackageReference Include="Microsoft.CognitiveServices.Speech" Version="$(MicrosoftSpeechVersion)" />
<PackageReference Include="Microsoft.CognitiveServices.Speech.Extension.MAS" Version="$(MicrosoftSpeechVersion)" ExcludeAssets="build" /> <PackageReference Include="Microsoft.CognitiveServices.Speech.Extension.MAS" Version="$(MicrosoftSpeechVersion)" ExcludeAssets="build" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageReference Include="NAudio" Version="2.3.0" /> <PackageReference Include="NAudio" Version="2.3.0" />
<PackageReference Include="NCalcSync" Version="5.12.0" /> <PackageReference Include="NCalcSync" Version="5.12.0" />
<PackageReference Include="RazorLight" Version="2.3.1" /> <PackageReference Include="RazorLight" Version="2.3.1" />
<PackageReference Include="System.Drawing.Common" Version="10.0.9" /> <PackageReference Include="System.Drawing.Common" Version="10.0.9" />
<PackageReference Include="Whisper.net" Version="1.9.1" /> <PackageReference Include="Whisper.net" Version="1.9.0" />
<PackageReference Include="Whisper.net.Runtime" Version="1.9.0" /> <PackageReference Include="Whisper.net.Runtime" Version="1.9.0" />
<PackageReference Include="YamlDotNet" Version="18.0.0" /> <PackageReference Include="YamlDotNet" Version="18.0.0" />
</ItemGroup> </ItemGroup>
@@ -1,24 +0,0 @@
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace MeetingAssistant.MeetingNotes;
internal static class FrontmatterYamlDeserializer
{
public static IDeserializer Create()
{
return new DeserializerBuilder()
.WithTypeConverter(new ScalarStringListYamlTypeConverter())
.IgnoreUnmatchedProperties()
.Build();
}
public static IDeserializer CreateWithUnderscoredNaming()
{
return new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.WithTypeConverter(new ScalarStringListYamlTypeConverter())
.IgnoreUnmatchedProperties()
.Build();
}
}
@@ -16,7 +16,9 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
{ {
this.options = options.Value; this.options = options.Value;
this.logger = logger; this.logger = logger;
yamlDeserializer = FrontmatterYamlDeserializer.Create(); yamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
} }
public async Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken) public async Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
@@ -1,38 +0,0 @@
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
using YamlDotNet.Serialization;
namespace MeetingAssistant.MeetingNotes;
internal sealed class ScalarStringListYamlTypeConverter : IYamlTypeConverter
{
public bool Accepts(Type type)
{
return type == typeof(List<string>);
}
public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer)
{
if (parser.TryConsume<Scalar>(out var scalar))
{
return string.IsNullOrWhiteSpace(scalar.Value)
? []
: new List<string> { scalar.Value };
}
var values = new List<string>();
parser.Consume<SequenceStart>();
while (!parser.TryConsume<SequenceEnd>(out _))
{
var item = parser.Consume<Scalar>();
values.Add(item.Value);
}
return values;
}
public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer)
{
serializer(value, typeof(IEnumerable<string>));
}
}
@@ -7,7 +7,9 @@ public sealed record BoundMeetingProject(string Name, string Path);
public sealed class BoundMeetingProjectResolver public sealed class BoundMeetingProjectResolver
{ {
private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.Create(); private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
private readonly MeetingAssistantOptions options; private readonly MeetingAssistantOptions options;
@@ -5,7 +5,9 @@ namespace MeetingAssistant.Summary;
internal static class MeetingSummaryFrontmatterFactory internal static class MeetingSummaryFrontmatterFactory
{ {
private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.Create(); private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
public static async Task<MeetingArtifactFrontmatter> CreateAsync( public static async Task<MeetingArtifactFrontmatter> CreateAsync(
MeetingSessionArtifacts artifacts, MeetingSessionArtifacts artifacts,
@@ -7,7 +7,9 @@ namespace MeetingAssistant.Summary;
public sealed class MeetingSummaryTools public sealed class MeetingSummaryTools
{ {
private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.Create(); private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
private readonly MeetingSessionArtifacts artifacts; private readonly MeetingSessionArtifacts artifacts;
private readonly MeetingAssistantOptions options; private readonly MeetingAssistantOptions options;
@@ -12,12 +12,16 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using YamlDotNet.Core; using YamlDotNet.Core;
using YamlDotNet.Serialization; using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace MeetingAssistant.Workflow; namespace MeetingAssistant.Workflow;
public sealed class WorkflowRulesEditorTools public sealed class WorkflowRulesEditorTools
{ {
private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.CreateWithUnderscoredNaming(); private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{ {
WriteIndented = true WriteIndented = true
-6
View File
@@ -48,12 +48,6 @@ Generated artifact notes SHALL link only to the other notes from the same run an
- **WHEN** Meeting Assistant creates a meeting note - **WHEN** Meeting Assistant creates a meeting note
- **THEN** the note frontmatter links to the configured transcript, assistant context, and summary note locations - **THEN** the note frontmatter links to the configured transcript, assistant context, and summary note locations
#### Scenario: Scalar list frontmatter is normalized
- **GIVEN** a meeting note frontmatter field that Meeting Assistant expects as a list is stored as a scalar string
- **WHEN** Meeting Assistant reads the meeting note
- **THEN** it treats the scalar string as a one-item list
- **AND** continues processing the meeting note
#### Scenario: Generated artifacts do not self-reference #### Scenario: Generated artifacts do not self-reference
- **WHEN** Meeting Assistant writes transcript, assistant context, or summary artifact frontmatter - **WHEN** Meeting Assistant writes transcript, assistant context, or summary artifact frontmatter
- **THEN** the artifact frontmatter links to the other run notes - **THEN** the artifact frontmatter links to the other run notes