using MeetingAssistant.MeetingNotes; using MeetingAssistant.Workflow; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace MeetingAssistant.Tests; public sealed class MeetingWorkflowEngineTests { [Fact] public async Task CreatedRuleAddsDefaultAttendeeOnceWhenMeetingHasNoAttendees() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: add-me on: - created: {} if: - condition: meeting.attendees.count = 0 steps: - uses: add_attendee value: Manuel """); await fixture.Engine.RunAsync( MeetingWorkflowEvent.Created(fixture.Artifacts), fixture.Options, CancellationToken.None); await fixture.Engine.RunAsync( MeetingWorkflowEvent.Created(fixture.Artifacts), fixture.Options, CancellationToken.None); var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None); Assert.Equal(["Manuel"], meeting.Frontmatter.Attendees); } [Fact] public async Task StateTransitionRuleCanCleanTitleAndAddProjectWithNestedConditions() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: clean-teams-title on: - state_transition: from: collecting metadata to: transcribing if: - and: - condition: contains(meeting.title, '[Teams]') - not: condition: contains(meeting.projects, 'Architecture') steps: - uses: set_property property: title value: '@Model.Meeting.Title.Replace("[Teams]", "").Trim()' - uses: add_project value: Architecture """, title: "[Teams] Architecture Sync"); 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("Architecture Sync", meeting.Frontmatter.Title); 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() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: ada-context on: - speaker_identified: name: Ada if: - or: - condition: contains(meeting.title, 'Architecture') - condition: meeting.attendees.count > 3 steps: - uses: add_context value: 'Speaker @Model.Speaker.Name was identified in @Model.Meeting.Title.' """, title: "Architecture Sync"); await fixture.Engine.RunAsync( MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Grace"), fixture.Options, CancellationToken.None); await fixture.Engine.RunAsync( MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Ada"), fixture.Options, CancellationToken.None); var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath); Assert.DoesNotContain("Grace", context); Assert.Contains("Speaker Ada was identified in Architecture Sync.", context); } [Fact] public async Task TranscriptLineRuleCanReplaceMaskedProfanityBeforeWriting() { var fixture = await WorkflowFixture.CreateAsync(MeetingWorkflowTestRules.MaskedProfanityRedactionYaml); var line = await fixture.Engine.TransformTranscriptLineAsync( MeetingWorkflowEvent.TranscriptLine( fixture.Artifacts, "[00:00:04] Guest-1: Azure returned ***** here.", "Guest-1"), fixture.Options, CancellationToken.None); Assert.Equal("[00:00:04] Guest-1: Azure returned [redacted] here.", line); } [Fact] public async Task TranscriptLineRulePreservesUtf8CharactersWhenRenderingRazor() { var fixture = await WorkflowFixture.CreateAsync(MeetingWorkflowTestRules.MaskedProfanityRedactionYaml); var line = await fixture.Engine.TransformTranscriptLineAsync( MeetingWorkflowEvent.TranscriptLine( fixture.Artifacts, "[00:00:57] Guest-1: Das heißt, Schimpfwörter wie ***** müssen als nächstes richtig bleiben.", "Guest-1"), fixture.Options, CancellationToken.None); Assert.Equal( "[00:00:57] Guest-1: Das heißt, Schimpfwörter wie [redacted] müssen als nächstes richtig bleiben.", line); } [Fact] public async Task TriggeredWorkflowRuleLogsStartAndCompletion() { var logger = new ListLogger(); var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: logged-rule on: - created: {} steps: - uses: add_project value: Diagnostics """, logger: logger); await RunCreatedAsync(fixture); Assert.Contains(logger.Messages, message => message.Contains("Triggered meeting workflow rule logged-rule for event Created", StringComparison.Ordinal)); Assert.Contains(logger.Messages, message => message.Contains("Completed meeting workflow rule logged-rule for event Created", StringComparison.Ordinal)); } [Fact] public async Task WorkflowRuleFailureLogsRuleNameAndEventType() { var logger = new ListLogger(); var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: broken-rule on: - created: {} steps: - uses: set_property property: unsupported value: Diagnostics """, logger: logger); await Assert.ThrowsAsync(() => RunCreatedAsync(fixture)); Assert.Contains(logger.Messages, message => message.Contains("Meeting workflow rule broken-rule failed for event Created", StringComparison.Ordinal)); } [Fact] public async Task RuleCanRemoveAttendeeWhenNestedConditionMatches() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: remove-placeholder on: - created: {} if: - and: - condition: contains(meeting.attendees, 'Placeholder') - not: condition: contains(meeting.title, 'Keep Placeholder') steps: - uses: remove_attendee value: Placeholder """, attendees: ["Placeholder", "Ada"]); await fixture.Engine.RunAsync( MeetingWorkflowEvent.Created(fixture.Artifacts), fixture.Options, CancellationToken.None); var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None); Assert.Equal(["Ada"], meeting.Frontmatter.Attendees); } [Theory] [InlineData("created: {}", "created", null, null, null, true)] [InlineData("created: {}", "speaker", null, null, "Ada", false)] [InlineData("state_transition:\n from: collecting metadata", "state", "collecting metadata", "speaker recognition", null, true)] [InlineData("state_transition:\n to: transcribing", "state", "collecting metadata", "transcribing", null, true)] [InlineData("state_transition:\n from: collecting metadata\n to: transcribing", "state", "collecting metadata", "transcribing", null, true)] [InlineData("state_transition:\n from: transcribing\n to: summarizing", "state", "collecting metadata", "transcribing", null, false)] [InlineData("state_transition:\n from: COLLECTING METADATA\n to: TRANSCRIBING", "state", "collecting metadata", "transcribing", null, true)] [InlineData("speaker_identified:\n name: Ada", "speaker", null, null, "Ada", true)] [InlineData("speaker_identified:\n name: ADA", "speaker", null, null, "ada", true)] [InlineData("speaker_identified:\n name: Ada", "speaker", null, null, "Grace", false)] [InlineData("speaker_identified: {}", "speaker", null, null, "Grace", true)] [InlineData("attendee_added:\n equals: Ada Lovelace", "attendee", null, null, "ada lovelace", true)] [InlineData("attendee_added:\n contains: contoso", "attendee", null, null, "Ada (Contoso)", true)] [InlineData("attendee_added:\n regex: '^Ada .+Contoso\\)$'", "attendee", null, null, "Ada (Contoso)", true)] [InlineData("attendee_added:\n regex: '^Grace'", "attendee", null, null, "Ada (Contoso)", false)] public async Task TriggerMatchingScenarios( string triggerYaml, string eventKind, string? from, string? to, string? speaker, bool shouldRun) { var stepYaml = eventKind == "attendee" ? """ - uses: set_property property: attendee.name value: Triggered """ : """ - uses: add_project value: Triggered """; var fixture = await WorkflowFixture.CreateAsync( $$""" rules: - name: trigger-scenario on: - {{triggerYaml}} steps: {{stepYaml}} """); var workflowEvent = CreateEvent(eventKind, fixture.Artifacts, from, to, speaker); if (eventKind == "attendee") { var transformed = await fixture.Engine.TransformAttendeeAsync( workflowEvent, fixture.Options, CancellationToken.None); Assert.Equal(shouldRun ? "Triggered" : speaker, transformed); return; } await fixture.Engine.RunAsync(workflowEvent, fixture.Options, CancellationToken.None); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(shouldRun, meeting.Frontmatter.Projects.Contains("Triggered")); } [Theory] [InlineData("event.type = 'SpeakerIdentified'", "speaker", null, null, "Ada", true)] [InlineData("state.from = 'collecting metadata' and state.to = 'transcribing'", "state", "collecting metadata", "transcribing", null, true)] [InlineData("meeting.state = 'transcribing'", "state", "collecting metadata", "transcribing", null, true)] [InlineData("speaker.name = 'Ada'", "speaker", null, null, "Ada", true)] [InlineData("speaker.name = 'Ada'", "speaker", null, null, "Grace", false)] [InlineData("starts_with(meeting.title, 'planning')", "created", null, null, null, true)] [InlineData("ends_with(meeting.title, 'sync')", "created", null, null, null, true)] [InlineData("contains(meeting.title, 'ANNING S')", "created", null, null, null, true)] [InlineData("contains(meeting.attendees, 'Ada')", "created", null, null, null, true)] [InlineData("contains(meeting.projects, 'Architecture')", "created", null, null, null, true)] [InlineData("meeting.attendees.count >= 2", "created", null, null, null, true)] public async Task ExpressionConditionParameterScenarios( string condition, string eventKind, string? from, string? to, string? speaker, bool shouldRun) { var fixture = await WorkflowFixture.CreateAsync( $$""" rules: - name: condition-scenario on: - created: {} - state_transition: {} - speaker_identified: {} if: - condition: {{condition}} steps: - uses: add_project value: ConditionMatched """, title: "Planning Sync", attendees: ["Ada", "Grace"], projects: ["Architecture"]); await fixture.Engine.RunAsync( CreateEvent(eventKind, fixture.Artifacts, from, to, speaker), fixture.Options, CancellationToken.None); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(shouldRun, meeting.Frontmatter.Projects.Contains("ConditionMatched")); } [Fact] public async Task TopLevelConditionsAreCombinedWithAnd() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: all-top-level-conditions on: - created: {} if: - condition: contains(meeting.title, 'Planning') - condition: contains(meeting.attendees, 'Ada') - condition: contains(meeting.projects, 'Missing') steps: - uses: add_project value: ShouldNotRun """, attendees: ["Ada"], projects: ["Architecture"]); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.DoesNotContain("ShouldNotRun", meeting.Frontmatter.Projects); } [Fact] public async Task OrGroupSkipsRuleWhenAllBranchesAreFalse() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: false-or on: - created: {} if: - or: - condition: contains(meeting.title, 'Budget') - condition: contains(meeting.attendees, 'Grace') steps: - uses: add_project value: ShouldNotRun """, attendees: ["Ada"]); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.DoesNotContain("ShouldNotRun", meeting.Frontmatter.Projects); } [Fact] public async Task EmptyConditionListRunsWhenTriggerMatches() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: no-if on: - created: {} steps: - uses: add_project value: NoConditionNeeded """); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.Contains("NoConditionNeeded", meeting.Frontmatter.Projects); } [Fact] public async Task RuleWithMultipleTriggersRunsWhenAnyTriggerMatches() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: multi-trigger on: - created: {} - speaker_identified: name: Ada steps: - uses: add_project value: MultiTrigger """); await fixture.Engine.RunAsync( MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Ada"), fixture.Options, CancellationToken.None); var meeting = await fixture.ReadMeetingAsync(); Assert.Contains("MultiTrigger", meeting.Frontmatter.Projects); } [Fact] public async Task MultipleRulesRunInFileOrderAndLaterRulesSeeEarlierMutations() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: first on: - created: {} steps: - uses: add_attendee value: Ada - name: second on: - created: {} if: - condition: contains(meeting.attendees, 'Ada') steps: - uses: add_project value: Architecture """); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(["Ada"], meeting.Frontmatter.Attendees); Assert.Equal(["Architecture"], meeting.Frontmatter.Projects); } [Fact] public async Task LaterStepsInSameRuleSeeEarlierStepMutationsInRazorModel() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: step-order on: - created: {} steps: - uses: add_attendee value: Ada - uses: add_context value: 'Attendees: @Model.Meeting.Attendees.Count' """); await RunCreatedAsync(fixture); var context = await fixture.ReadContextAsync(); Assert.Contains("Attendees: 1", context); } [Fact] public async Task SpeakerIdentifiedTemplateCanUseRecognizedSpeakerName() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: speaker-template on: - speaker_identified: {} steps: - uses: add_context value: '@Model.Speaker.Name joined @Model.Meeting.Title' """, title: "Design Review"); await fixture.Engine.RunAsync( MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Manuel"), fixture.Options, CancellationToken.None); var context = await fixture.ReadContextAsync(); Assert.Contains("Manuel joined Design Review", context); } [Fact] public async Task StateTransitionTemplateCanUseFromAndToStates() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: transition-template on: - state_transition: to: transcribing steps: - uses: add_context value: '@Model.Event.From -> @Model.Event.To' """); await fixture.Engine.RunAsync( MeetingWorkflowEvent.StateTransition( fixture.Artifacts, AssistantContextState.CollectingMetadata, AssistantContextState.Transcribing), fixture.Options, CancellationToken.None); var context = await fixture.ReadContextAsync(); Assert.Contains("collecting metadata -> transcribing", context); } [Fact] public async Task AddAttendeeNormalizesDisplayNameFromEmailAddress() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: normalize-attendee on: - created: {} steps: - uses: add_attendee value: 'Ada Lovelace ' """); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees); } [Fact] public async Task AttendeeAddedRuleCanTransformWorkflowAddedAttendee() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: add-raw-attendee on: - created: {} steps: - uses: add_attendee value: 'Ada Lovelace (Contoso)' - name: clean-contoso-attendee on: - attendee_added: contains: 'Contoso' steps: - uses: set_property property: attendee.name value: '@Model.Attendee.Name.Replace(" (Contoso)", "")' """); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees); } [Fact] public async Task AddAttendeeDoesNotDuplicateExistingDisplayNameFromEmailAddress() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: dedupe-attendee on: - created: {} steps: - uses: add_attendee value: Ada """, attendees: ["Ada "]); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(["Ada "], meeting.Frontmatter.Attendees); } [Fact] public async Task RemoveAttendeeMatchesExistingDisplayNameFromEmailAddress() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: remove-email-attendee on: - created: {} steps: - uses: remove_attendee value: Ada """, attendees: ["Ada ", "Grace"]); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(["Grace"], meeting.Frontmatter.Attendees); } [Fact] public async Task AddProjectDoesNotDuplicateCaseInsensitiveProject() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: dedupe-project on: - created: {} steps: - uses: add_project value: architecture """, projects: ["Architecture"]); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(["Architecture"], meeting.Frontmatter.Projects); } [Fact] public async Task SetPropertyCanUseNameAliasForTitle() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: title-by-name on: - created: {} steps: - uses: set_property name: meeting.title value: Updated Title """); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal("Updated Title", meeting.Frontmatter.Title); } [Fact] public async Task AddContextDoesNotRequireMeetingNoteMutationToPersist() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: context-only on: - created: {} steps: - uses: add_context value: Context-only note. """); await RunCreatedAsync(fixture); var context = await fixture.ReadContextAsync(); Assert.Contains("Context-only note.", context); } [Fact] public async Task RulesFileChangesAreReadForTheNextWorkflowEvent() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var rulesPath = Path.Combine(root, "rules.yaml"); Directory.CreateDirectory(root); var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: first-version on: - created: {} steps: - uses: add_project value: First """, rulesPath: rulesPath); await RunCreatedAsync(fixture); await File.WriteAllTextAsync( rulesPath, """ rules: - name: second-version on: - speaker_identified: {} steps: - uses: add_project value: Second """); await fixture.Engine.RunAsync( MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Ada"), fixture.Options, CancellationToken.None); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(["First", "Second"], meeting.Frontmatter.Projects); } [Fact] public async Task BlankRulesFileDoesNothing() { var fixture = await WorkflowFixture.CreateAsync(" "); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.Empty(meeting.Frontmatter.Attendees); Assert.Empty(meeting.Frontmatter.Projects); } [Fact] public async Task MissingRulesFileDoesNothing() { var fixture = await WorkflowFixture.CreateAsync("", rulesPath: Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "missing.yaml")); await fixture.Engine.RunAsync( MeetingWorkflowEvent.Created(fixture.Artifacts), fixture.Options, CancellationToken.None); var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None); Assert.Empty(meeting.Frontmatter.Attendees); } private static Task RunCreatedAsync(WorkflowFixture fixture) { return fixture.Engine.RunAsync( MeetingWorkflowEvent.Created(fixture.Artifacts), fixture.Options, CancellationToken.None); } private static MeetingWorkflowEvent CreateEvent( string eventKind, MeetingSessionArtifacts artifacts, string? from, string? to, string? speaker) { return eventKind switch { "created" => MeetingWorkflowEvent.Created(artifacts), "speaker" => MeetingWorkflowEvent.SpeakerIdentified(artifacts, speaker ?? "Ada"), "attendee" => MeetingWorkflowEvent.AttendeeAdded(artifacts, speaker ?? "Ada"), "state" => MeetingWorkflowEvent.StateTransition( artifacts, ParseState(from ?? "collecting metadata"), ParseState(to ?? "transcribing")), _ => throw new InvalidOperationException($"Unknown workflow event kind '{eventKind}'.") }; } private static AssistantContextState ParseState(string state) { return state.Trim().ToLowerInvariant() switch { "collecting metadata" => AssistantContextState.CollectingMetadata, "transcribing" => AssistantContextState.Transcribing, "speaker recognition" => AssistantContextState.SpeakerRecognition, "summarizing" => AssistantContextState.Summarizing, "finished" => AssistantContextState.Finished, "error" => AssistantContextState.Error, _ => throw new InvalidOperationException($"Unknown workflow state '{state}'.") }; } private sealed class WorkflowFixture { private WorkflowFixture( MeetingAssistantOptions options, MarkdownMeetingNoteStore noteStore, MeetingSessionArtifacts artifacts, MeetingWorkflowEngine engine) { Options = options; NoteStore = noteStore; Artifacts = artifacts; Engine = engine; } public MeetingAssistantOptions Options { get; } public MarkdownMeetingNoteStore NoteStore { get; } public MeetingSessionArtifacts Artifacts { get; } public MeetingWorkflowEngine Engine { get; } public static async Task CreateAsync( string rulesYaml, string title = "Planning Sync", IReadOnlyList? attendees = null, IReadOnlyList? projects = null, string? rulesPath = null, ILogger? logger = null) { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); rulesPath ??= Path.Combine(root, "rules.yaml"); if (!string.IsNullOrEmpty(rulesYaml)) { await File.WriteAllTextAsync(rulesPath, rulesYaml); } var options = new MeetingAssistantOptions { Vault = new VaultOptions { BaseFolder = root, MeetingNotesFolder = "Notes", TranscriptsFolder = "Transcripts", AssistantContextFolder = "Context", SummariesFolder = "Summaries", ProjectsFolder = "Projects" }, Automation = new AutomationOptions { RulesPath = rulesPath } }; var noteStore = new MarkdownMeetingNoteStore( Microsoft.Extensions.Options.Options.Create(options), NullLogger.Instance); var artifactStore = new MarkdownMeetingArtifactStore( NullLogger.Instance); 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 meeting = await noteStore.SaveAsync( MeetingNoteTemplate.Create( title, DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"), attendees: attendees ?? [], projects: projects ?? [], transcriptPath: artifacts.TranscriptPath, assistantContextPath: artifacts.AssistantContextPath, summaryPath: artifacts.SummaryPath) with { Path = artifacts.MeetingNotePath }, options, CancellationToken.None); await artifactStore.CreateAssistantContextAsync( artifacts, meeting, "", null, CancellationToken.None); var engine = new MeetingWorkflowEngine( new FileMeetingWorkflowRulesProvider( NullLogger.Instance), noteStore, artifactStore, logger ?? NullLogger.Instance); return new WorkflowFixture(options, noteStore, artifacts, engine); } public Task ReadMeetingAsync() { return NoteStore.ReadAsync(Artifacts.MeetingNotePath, CancellationToken.None); } public Task ReadContextAsync() { return File.ReadAllTextAsync(Artifacts.AssistantContextPath); } } private sealed class ListLogger : ILogger { public List Messages { get; } = []; public IDisposable BeginScope(TState state) where TState : notnull { return NullScope.Instance; } public bool IsEnabled(LogLevel logLevel) { return true; } public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { Messages.Add(formatter(state, exception)); } private sealed class NullScope : IDisposable { public static NullScope Instance { get; } = new(); public void Dispose() { } } } }