using MeetingAssistant.Workflow; using MeetingAssistant.Logging; using MeetingAssistant.Speakers; using Microsoft.Extensions.Configuration; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using System.Text.Json; namespace MeetingAssistant.Tests; public sealed class WorkflowRulesEditorTests { [Fact] public void EditorOptionsInheritSummarizerDefaultsAndOverrideConfiguredValues() { var defaults = new AgentOptions { Endpoint = "https://summary.example", Key = "summary-key", KeyEnv = "SUMMARY_KEY", Model = "summary-model", UseStreaming = true, EnableThinking = true, ReasoningEffort = ReasoningEffortOption.High, ReconnectionAttempts = 7, ReconnectionDelay = TimeSpan.FromSeconds(7), ContextWindowTokens = 1000, MaxOutputTokens = 100, EnableCompaction = true, CompactionRemainingRatio = 0.2, ResponsesCompactPath = "responses/compact" }; var editor = new WorkflowRulesEditorOptions { Model = "editor-model", UseStreaming = false, EnableThinking = false, MaxOutputTokens = 50 }; var effective = editor.ToEffectiveAgentOptions(defaults); Assert.Equal("https://summary.example", effective.Endpoint); Assert.Equal("summary-key", effective.Key); Assert.Equal("SUMMARY_KEY", effective.KeyEnv); Assert.Equal("editor-model", effective.Model); Assert.False(effective.UseStreaming); Assert.False(effective.EnableThinking); Assert.Equal(ReasoningEffortOption.High, effective.ReasoningEffort); Assert.Equal(7, effective.ReconnectionAttempts); Assert.Equal(TimeSpan.FromSeconds(7), effective.ReconnectionDelay); Assert.Equal(1000, effective.ContextWindowTokens); Assert.Equal(50, effective.MaxOutputTokens); Assert.True(effective.EnableCompaction); Assert.Equal(0.2, effective.CompactionRemainingRatio); Assert.Equal("responses/compact", effective.ResponsesCompactPath); } [Fact] public async Task RulesEditorToolsReadWriteAndSearchOnlyConfiguredRulesFile() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var rulesPath = Path.Combine(root, "rules.yaml"); var unrelatedPath = Path.Combine(root, "other.yaml"); await File.WriteAllTextAsync(unrelatedPath, "rules:\n- name: do-not-find\n"); var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions { Automation = new AutomationOptions { RulesPath = rulesPath } }); var writeResult = await tools.WriteRules(""" rules: - name: add-me on: - created: {} steps: - uses: add_attendee value: Manuel """); var readResult = await tools.ReadRules(); var searchResult = await tools.Search("add-me"); Assert.Equal(rulesPath, writeResult); Assert.Contains("name: add-me", readResult); Assert.Contains("rules.yaml:", searchResult); Assert.DoesNotContain("other.yaml", searchResult); } [Fact] public async Task SettingsToolsReadWriteAndValidateConfiguredAppsettingsFile() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var configPath = Path.Combine(root, "appsettings.json"); await File.WriteAllTextAsync( configPath, """ { "MeetingAssistant": { "Agent": { "Model": "old-model" } } } """); var tools = new WorkflowRulesEditorTools( new MeetingAssistantOptions(), configPath: configPath); var readResult = await tools.ReadConfig(from: 3, to: 5); var writeResult = await tools.WriteConfig(""" { "MeetingAssistant": { "Agent": { "Model": "new-model" } } } """); var invalidResult = await tools.WriteConfig("{ invalid json"); Assert.Contains("\"Agent\"", readResult); Assert.Equal(configPath, writeResult); Assert.StartsWith("Refused: appsettings JSON is invalid.", invalidResult); Assert.Contains("new-model", await File.ReadAllTextAsync(configPath)); Assert.DoesNotContain("invalid json", await File.ReadAllTextAsync(configPath)); } [Fact] public async Task SettingsToolsReadConfigurationDocumentation() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var docsPath = Path.Combine(root, "meeting-assistant-configuration.md"); await File.WriteAllTextAsync(docsPath, "# Config Docs\n\nAgent model settings."); var tools = new WorkflowRulesEditorTools( new MeetingAssistantOptions(), configDocsPath: docsPath); var docs = await tools.ReadConfigDocs(); Assert.Contains("Agent model settings", docs); } [Fact] public async Task SettingsToolsReadAndSearchApplicationLogs() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); await File.WriteAllTextAsync( Path.Combine(root, MeetingAssistantLogFiles.CurrentLogFileName), "first line\nsecond needle\nthird line\n"); await File.WriteAllTextAsync( Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(1)), "rotated needle\n"); var tools = new WorkflowRulesEditorTools( new MeetingAssistantOptions(), logDirectory: root); using var provider = new MeetingAssistantFileLoggerProvider(root); provider.CreateLogger("Test").LogInformation("locked needle"); var tail = await tools.ReadLogs(tail: 2); var range = await tools.ReadLogs(from: 1, to: 1); var search = await tools.SearchLogs("needle"); Assert.Contains("locked needle", tail); Assert.Contains("locked needle", search); Assert.Contains("locked needle", range); Assert.Contains("meeting-assistant.log:1", search); Assert.Contains("meeting-assistant.log.2:1 rotated needle", search); } [Fact] public async Task SettingsToolsReadAndSearchOnlyCopiedSpecFiles() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var sessionSpec = Path.Combine(root, "meeting-session", "spec.md"); Directory.CreateDirectory(Path.GetDirectoryName(sessionSpec)!); await File.WriteAllTextAsync( sessionSpec, """ # meeting-session Specification Meeting Assistant creates notes. """); await File.WriteAllTextAsync( Path.Combine(root, "root-spec.md"), "Root spec mentions Assistant."); var outside = Path.Combine(Path.GetDirectoryName(root)!, "outside.md"); await File.WriteAllTextAsync(outside, "outside Assistant"); var tools = new WorkflowRulesEditorTools( new MeetingAssistantOptions(), specRootPath: root); var read = await tools.ReadSpecFile("meeting-session/spec.md", from: 3, to: 3); var escaped = await tools.ReadSpecFile("../outside.md"); var search = await tools.SearchSpec("Assistant"); Assert.Equal("Meeting Assistant creates notes.", read.Trim()); Assert.StartsWith("Refused: spec path must stay inside", escaped); Assert.Contains("meeting-session/spec.md:3 Meeting Assistant creates notes.", search); Assert.Contains("root-spec.md:1 Root spec mentions Assistant.", search); Assert.DoesNotContain("outside.md", search); } [Fact] public async Task SettingsToolsReadTailAndSearchSpecsWithFilePattern() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var sessionSpec = Path.Combine(root, "meeting-session", "spec.md"); var otherSpec = Path.Combine(root, "other", "notes.md"); Directory.CreateDirectory(Path.GetDirectoryName(sessionSpec)!); Directory.CreateDirectory(Path.GetDirectoryName(otherSpec)!); await File.WriteAllTextAsync(sessionSpec, "one\nsession needle\nthree\nfour"); await File.WriteAllTextAsync(otherSpec, "other needle"); var tools = new WorkflowRulesEditorTools( new MeetingAssistantOptions(), specRootPath: root); var tail = await tools.ReadSpecFile("meeting-session/spec.md", tail: 2); var search = await tools.SearchSpec("needle", file_pattern: "meeting-session/*.md"); Assert.Equal("three\nfour", tail); Assert.Contains("meeting-session/spec.md:2 session needle", search); Assert.DoesNotContain("other/notes.md", search); } [Fact] public async Task SettingsToolsCreateProjectWithRecommendedSeedOrDirectAgentsFile() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var projectsRoot = Path.Combine(root, "Projects"); var agentsTemplatePath = Path.Combine(root, "Project-AGENTS.md"); Directory.CreateDirectory(root); await File.WriteAllTextAsync(agentsTemplatePath, "Recommended project instructions."); var tools = new WorkflowRulesEditorTools( new MeetingAssistantOptions { Vault = { ProjectsFolder = projectsRoot } }, projectAgentsTemplatePath: agentsTemplatePath); var recommended = await tools.CreateProject("Alpha Project"); var direct = await tools.CreateProject( "Beta Project", seed: "agents_md", agents_md: "Direct project instructions."); var alphaRoot = Path.Combine(projectsRoot, "Alpha Project"); var betaRoot = Path.Combine(projectsRoot, "Beta Project"); Assert.Equal("Alpha Project", recommended); Assert.Contains("Alpha Project", await tools.ListProjects()); Assert.Equal("Recommended project instructions.", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "AGENTS.md"))); Assert.Contains("# Executive Summary", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "PROJECT.md"))); Assert.Contains("project folder was created", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "JOURNAL.md"))); Assert.Contains("Important decisions", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "DECISIONS.md"))); Assert.Equal("Direct project instructions.", await File.ReadAllTextAsync(Path.Combine(betaRoot, "AGENTS.md"))); Assert.False(File.Exists(Path.Combine(betaRoot, "PROJECT.md"))); Assert.StartsWith("Refused:", await tools.CreateProject("../Escape")); var missingTemplateTools = new WorkflowRulesEditorTools( new MeetingAssistantOptions { Vault = { ProjectsFolder = projectsRoot } }, projectAgentsTemplatePath: Path.Combine(root, "Missing-Project-AGENTS.md")); var missingTemplate = await missingTemplateTools.CreateProject("Gamma Project"); Assert.StartsWith("Refused: recommended project AGENTS.md template was not found:", missingTemplate); Assert.False(Directory.Exists(Path.Combine(projectsRoot, "Gamma Project"))); } [Fact] public async Task SettingsToolsReadWriteAndSearchExistingProjectFiles() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var projectsRoot = Path.Combine(root, "Projects"); var alphaRoot = Path.Combine(projectsRoot, "Alpha"); var betaRoot = Path.Combine(projectsRoot, "Beta"); Directory.CreateDirectory(alphaRoot); Directory.CreateDirectory(betaRoot); await File.WriteAllTextAsync(Path.Combine(alphaRoot, "PROJECT.md"), "alpha one\nalpha needle\nalpha three"); await File.WriteAllTextAsync(Path.Combine(alphaRoot, "notes.txt"), "alpha needle hidden from md search"); await File.WriteAllTextAsync(Path.Combine(betaRoot, "PROJECT.md"), "beta needle"); var tools = new WorkflowRulesEditorTools( new MeetingAssistantOptions { Vault = { ProjectsFolder = projectsRoot } }); var files = await tools.ListProjectFiles("Alpha"); var tail = await tools.ReadProjectFile("Alpha", "PROJECT.md", tail: 2); var write = await tools.WriteProjectFile("Alpha", "JOURNAL.md", "created"); var search = await tools.SearchProjects("needle", file_pattern: "*.md"); Assert.Equal("notes.txt\nPROJECT.md", files); Assert.Equal("alpha needle\nalpha three", tail); Assert.Equal("Alpha/JOURNAL.md", write); Assert.Equal("created", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "JOURNAL.md"))); Assert.Contains("Alpha/PROJECT.md:2 alpha needle", search); Assert.Contains("Beta/PROJECT.md:1 beta needle", search); Assert.DoesNotContain("notes.txt", search); Assert.StartsWith("Refused:", await tools.ReadProjectFile("Missing", "PROJECT.md")); Assert.StartsWith("Refused:", await tools.WriteProjectFile("Alpha", "../escape.md", "bad")); } [Fact] public async Task SettingsToolsListReadAndSearchMeetingArtifacts() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var summariesRoot = Path.Combine(root, "Summaries"); var transcriptsRoot = Path.Combine(root, "Transcripts"); var notesRoot = Path.Combine(root, "Notes"); var contextRoot = Path.Combine(root, "Context"); Directory.CreateDirectory(summariesRoot); Directory.CreateDirectory(transcriptsRoot); Directory.CreateDirectory(notesRoot); Directory.CreateDirectory(contextRoot); await File.WriteAllTextAsync( Path.Combine(summariesRoot, "20260530-1000-summary.md"), """ --- title: Older Meeting start_time: "2026-05-30T10:00:00+02:00" meeting: "[[20260530-1000-note|Meeting Note]]" transcript: "[[20260530-1000-transcript|Transcript]]" assistant_context: "[[20260530-1000-context|Assistant Context]]" projects: - Alpha --- older summary needle """); await File.WriteAllTextAsync( Path.Combine(summariesRoot, "20260531-0900-summary.md"), """ --- title: Newer Meeting start_time: "2026-05-31T09:00:00+02:00" meeting: "[[20260531-0900-note|Meeting Note]]" transcript: "[[20260531-0900-transcript|Transcript]]" assistant_context: "[[20260531-0900-context|Assistant Context]]" projects: - Beta --- newer summary needle """); await File.WriteAllTextAsync(Path.Combine(notesRoot, "20260531-0900-note.md"), "note one\nnote needle\nnote three"); await File.WriteAllTextAsync(Path.Combine(transcriptsRoot, "20260531-0900-transcript.md"), "transcript one\ntranscript needle\ntranscript three"); await File.WriteAllTextAsync(Path.Combine(contextRoot, "20260531-0900-context.md"), "context one\ncontext needle\ncontext three"); var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions { Vault = { SummariesFolder = summariesRoot, TranscriptsFolder = transcriptsRoot, MeetingNotesFolder = notesRoot, AssistantContextFolder = contextRoot } }); var recent = await tools.ListRecentSummaries(limit: 2); var summary = await tools.ReadSummary("20260531-0900-summary.md", tail: 1); var transcript = await tools.ReadTranscript("20260531-0900-transcript.md", from: 2, to: 2); var meetingNote = await tools.ReadMeetingNote("20260531-0900-note.md", tail: 2); var context = await tools.ReadContext("20260531-0900-context.md", tail: 2); var summarySearch = await tools.SearchSummaries("needle"); var transcriptSearch = await tools.SearchTranscripts("needle"); var noteSearch = await tools.SearchMeetingNotes("needle"); var contextSearch = await tools.SearchContext("needle"); Assert.True(recent.IndexOf("20260531-0900-summary.md", StringComparison.Ordinal) < recent.IndexOf("20260530-1000-summary.md", StringComparison.Ordinal)); Assert.Contains("title: Newer Meeting", recent); Assert.Contains("meeting_note: 20260531-0900-note.md", recent); Assert.Contains("transcript: 20260531-0900-transcript.md", recent); Assert.Contains("context: 20260531-0900-context.md", recent); Assert.Equal("newer summary needle", summary.Trim()); Assert.Equal("transcript needle", transcript.Trim()); Assert.Equal("note needle\nnote three", meetingNote); Assert.Equal("context needle\ncontext three", context); Assert.Contains("20260531-0900-summary.md:", summarySearch); Assert.Contains("20260531-0900-transcript.md:2 transcript needle", transcriptSearch); Assert.Contains("20260531-0900-note.md:2 note needle", noteSearch); Assert.Contains("20260531-0900-context.md:2 context needle", contextSearch); Assert.StartsWith("Refused:", await tools.ReadSummary("../escape.md")); } [Fact] public async Task SettingsToolsWriteMeetingArtifactsAndFrontmatter() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var summariesRoot = Path.Combine(root, "summaries"); var transcriptsRoot = Path.Combine(root, "transcripts"); var notesRoot = Path.Combine(root, "meetings"); var contextRoot = Path.Combine(root, "context"); Directory.CreateDirectory(summariesRoot); Directory.CreateDirectory(transcriptsRoot); Directory.CreateDirectory(notesRoot); Directory.CreateDirectory(contextRoot); await File.WriteAllTextAsync( Path.Combine(notesRoot, "daily.md"), """ --- title: Old --- Existing body. """); await File.WriteAllTextAsync(Path.Combine(transcriptsRoot, "daily-transcript.md"), "one\nthree"); var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions { Vault = { SummariesFolder = summariesRoot, TranscriptsFolder = transcriptsRoot, MeetingNotesFolder = notesRoot, AssistantContextFolder = contextRoot } }); Assert.Equal("daily-summary.md", await tools.WriteSummary("daily-summary.md", "summary body", replace_file: true)); Assert.Equal("daily-transcript.md", await tools.WriteTranscript("daily-transcript.md", "two", insert: 2)); Assert.Equal("daily.md", await tools.WriteMeetingNoteFrontmatter( "daily.md", """ title: Fixed projects: - Alpha transcript: "[[daily-transcript|Transcript]]" """)); Assert.Equal("repair/context.md", await tools.WriteContext("repair/context.md", "context body")); 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"))) .Replace("\r\n", "\n", StringComparison.Ordinal); Assert.Contains("title: Fixed", note); Assert.Contains("projects:\n- Alpha", note); Assert.Contains("Existing body.", note); Assert.Equal("context body", await File.ReadAllTextAsync(Path.Combine(contextRoot, "repair", "context.md"))); Assert.StartsWith("Refused:", await tools.WriteSummary("../escape.md", "bad")); Assert.StartsWith("Refused:", await tools.WriteMeetingNoteFrontmatter("daily.md", "title: [")); } [Fact] public async Task SettingsToolsExposeDiagnosticsWithoutHttp() { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["MeetingAssistant:Automation:RulesPath"] = "rules.yaml" }) .Build(); var tools = new WorkflowRulesEditorTools( new MeetingAssistantOptions(), configuration: config); Assert.Contains("meeting-assistant", tools.GetHealth()); Assert.Equal("Recording coordinator is not configured.", tools.GetRecordingStatus()); Assert.Contains("rules.yaml", tools.ReloadWorkflowConfiguration()); Assert.Equal("Meeting metadata provider is not configured.", await tools.DiagnoseCurrentMeeting()); Assert.Equal("Speaker identity merge service is not configured.", await tools.MergeRecentSpeakerIdentities()); Assert.Equal("ASR diagnostic service is not configured.", await tools.DiagnoseAsrTranscribeFile("missing.wav")); Assert.Equal("ASR diagnostic service is not configured.", await tools.DiagnoseAsrDiarizeFile("missing.wav")); } [Fact] public void FileLoggerRotatesCurrentLogAndKeepsFourOlderFiles() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.CurrentLogFileName), "current"); File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(1)), "old1"); File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(2)), "old2"); File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(3)), "old3"); File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(4)), "old4"); using (var provider = new MeetingAssistantFileLoggerProvider(root)) { provider.CreateLogger("Test").LogInformation("fresh"); } Assert.Contains("fresh", File.ReadAllText(Path.Combine(root, MeetingAssistantLogFiles.CurrentLogFileName))); Assert.Equal("current", File.ReadAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(1)))); Assert.Equal("old3", File.ReadAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(4)))); Assert.False(File.Exists(Path.Combine(root, "meeting-assistant.log.5"))); } [Fact] public async Task RulesEditorToolsAppendByDefaultAndReplaceOnlyWhenRequested() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var rulesPath = Path.Combine(root, "rules.yaml"); await File.WriteAllTextAsync( rulesPath, """ rules: - name: existing on: - created: {} steps: - uses: add_attendee value: Ada """); var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions { Automation = new AutomationOptions { RulesPath = rulesPath } }); await tools.WriteRules(""" - name: appended on: - created: {} steps: - uses: add_attendee value: Grace """); var appended = await File.ReadAllTextAsync(rulesPath); Assert.Contains("name: existing", appended); Assert.Contains("name: appended", appended); await tools.WriteRules("rules: []", replace_file: true); Assert.Equal("rules: []", await File.ReadAllTextAsync(rulesPath)); } [Fact] public async Task RulesEditorToolsRefuseInvalidYamlWithoutOverwritingFile() { var fixture = await CreateRulesEditorFixtureAsync(); var result = await fixture.Tools.WriteRules("rules:\n- name: [", replace_file: true); Assert.StartsWith("Refused: workflow rules YAML is invalid.", result); Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath)); } [Fact] public async Task RulesEditorToolsRefuseWorkflowRulesThatWouldFailAtRuntime() { var fixture = await CreateRulesEditorFixtureAsync(); var result = await fixture.Tools.WriteRules(""" rules: - name: broken-step on: - created: {} steps: - uses: send_email value: Manuel """, replace_file: true); Assert.StartsWith("Refused: workflow rules are invalid.", result); Assert.Contains("broken-step", result); Assert.Contains("send_email", result); Assert.Contains("add_attendee", result); Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath)); } [Fact] public async Task RulesEditorToolsLogWorkflowRuleValidationFailures() { var logger = new CapturingLogger(); var fixture = await CreateRulesEditorFixtureAsync(logger); var result = await fixture.Tools.WriteRules(""" rules: - name: broken-step on: - created: {} steps: - uses: send_email value: Manuel """, replace_file: true); Assert.StartsWith("Refused: workflow rules are invalid.", result); Assert.Contains(logger.Messages, message => message.Contains(nameof(WorkflowRulesEditorTools.WriteRules), StringComparison.Ordinal) && message.Contains("send_email", StringComparison.Ordinal)); } [Fact] public async Task RulesEditorToolsExplainUnsupportedWorkflowProperties() { var fixture = await CreateRulesEditorFixtureAsync(); var result = await fixture.Tools.WriteRules(""" rules: - name: broken-property on: - created: {} steps: - uses: set_property property: meeting.status value: active """, replace_file: true); Assert.StartsWith("Refused: workflow rules are invalid.", result); Assert.Contains("broken-property", result); Assert.Contains("meeting.status", result); Assert.Contains("title", result); Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath)); } [Fact] public async Task RulesEditorToolsRefuseTranscriptLinePropertyOutsideTranscriptLineTrigger() { var fixture = await CreateRulesEditorFixtureAsync(); var result = await fixture.Tools.WriteRules(""" rules: - name: broken-transcript-property on: - created: {} steps: - uses: set_property property: transcript.line value: redacted """, replace_file: true); Assert.StartsWith("Refused: workflow rules are invalid.", result); Assert.Contains("broken-transcript-property", result); Assert.Contains("transcript.line", result); Assert.Contains("transcript_line", result); Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath)); } [Fact] public async Task RulesEditorToolsRefuseSideEffectingAttendeeAddedRules() { var fixture = await CreateRulesEditorFixtureAsync(); var result = await fixture.Tools.WriteRules(""" rules: - name: attendee-side-effect on: - attendee_added: contains: Contoso steps: - uses: add_context value: '@Model.Attendee.Name' """, replace_file: true); Assert.StartsWith("Refused: workflow rules are invalid.", result); Assert.Contains("attendee-side-effect", result); Assert.Contains("attendee.name", result); Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath)); } [Fact] public async Task RulesEditorToolsAcceptAndParseMaskedProfanityRedactionRule() { var fixture = await CreateRulesEditorFixtureAsync(); var result = await fixture.Tools.WriteRules(MeetingWorkflowTestRules.MaskedProfanityRedactionYaml, replace_file: true); Assert.Equal(fixture.RulesPath, result); var provider = new FileMeetingWorkflowRulesProvider( NullLogger.Instance); var rule = Assert.Single(await provider.GetRulesAsync(fixture.Options, CancellationToken.None)); Assert.Equal("redact-masked-profanity", rule.Name); Assert.NotNull(Assert.Single(rule.On).TranscriptLine); Assert.Equal("contains(transcript.line, '*****')", Assert.Single(rule.If).Condition); var step = Assert.Single(rule.Steps); Assert.Equal("set_property", step.Uses); Assert.Equal("transcript.line", step.Property); Assert.Equal( """@Model.Transcript.Line.Replace("*****", "[redacted]")""", step.Value); } [Fact] public async Task RulesEditorToolsRefuseMalformedRazorStepValues() { var fixture = await CreateRulesEditorFixtureAsync(); var result = await fixture.Tools.WriteRules(""" rules: - name: broken-razor on: - created: {} steps: - uses: add_context value: "Invalid template: @ThisIsNotAModelVar" """, replace_file: true); Assert.StartsWith("Refused: workflow rules are invalid.", result); Assert.Contains("broken-razor", result); Assert.Contains("Razor", result); Assert.Contains("ThisIsNotAModelVar", result); Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath)); } [Fact] public async Task RulesEditorToolsAllowValidRazorAndEmailStepValues() { var fixture = await CreateRulesEditorFixtureAsync(initialContent: ""); var result = await fixture.Tools.WriteRules(""" rules: - name: valid-razor on: - speaker_identified: {} steps: - uses: add_context value: "Speaker @Model.Speaker.Name can contact Support@example.com" """, replace_file: true); Assert.Equal(fixture.RulesPath, result); Assert.Contains("@Model.Speaker.Name", await File.ReadAllTextAsync(fixture.RulesPath)); Assert.Contains("Support@example.com", await File.ReadAllTextAsync(fixture.RulesPath)); } [Fact] public async Task InstructionBuilderIncludesConfiguredRulesPathAndWorkflowDocs() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var rulesPath = Path.Combine(root, "rules.yaml"); var builder = new WorkflowRulesEditorInstructionBuilder( NullLogger.Instance); var instructions = await builder.BuildAsync(new MeetingAssistantOptions { Automation = new AutomationOptions { RulesPath = rulesPath } }, CancellationToken.None); Assert.Contains(rulesPath, instructions); Assert.Contains("Meeting Workflow Engine", instructions); Assert.Contains("Workflow rules reference documentation", instructions); Assert.Contains("speaker identity database", instructions); Assert.Contains("read_config", instructions); 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.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); Assert.Contains("matching correction", instructions); } [Fact] public async Task InstructionBuilderTreatsAssistantContextAsMeetingMemoryDuringRepairs() { var builder = new WorkflowRulesEditorInstructionBuilder( NullLogger.Instance); var options = new MeetingAssistantOptions { WorkflowRulesEditor = new WorkflowRulesEditorOptions { InitialPrompt = "Custom interactive agent instructions." } }; var instructions = await builder.BuildAsync(options, CancellationToken.None); Assert.Contains("Custom interactive agent instructions.", instructions); Assert.Contains("meeting-specific memory", instructions); Assert.Contains("fix or investigate a meeting or summary", instructions); Assert.Contains("read the matching assistant context", instructions); Assert.Contains("problems, missing information, assumptions, prior fixes, and conclusions", instructions); Assert.Contains("append a concise record of your fixes and conclusions", instructions); } [Fact] public async Task RulesEditorToolsCrudAndSearchSpeakerIdentities() { await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync(); var identity = await fixture.AddIdentityAsync("Sabrina", aliases: ["Sabi"], candidates: ["Guest-01"], sample: [1]); var tools = fixture.CreateTools(); var searchResult = await tools.SearchIdentities("sabi"); var readResult = await tools.ReadIdentity(identity.Id); var updateResult = await tools.UpdateIdentity( identity.Id, "Sabrina M.", ["Sabrina"], ["Guest-02"]); var deleteResult = await tools.DeleteIdentity(identity.Id); Assert.Contains("\"displayName\": \"Sabrina\"", searchResult); Assert.Contains("\"canonicalName\": \"Sabrina\"", readResult); Assert.Contains("\"canonicalName\": \"Sabrina M.\"", updateResult); Assert.Equal($"Deleted identity {identity.Id}.", deleteResult); Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync()); } [Fact] public async Task RulesEditorToolsRefusesSamplelessSpeakerIdentityCreation() { await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync(); var tools = fixture.CreateTools(); var result = await tools.CreateIdentity("Sabrina", ["Sabi"], ["Guest-01"]); Assert.StartsWith("Refused: speaker identities require at least one audio sample.", result); Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync()); } [Fact] public async Task RulesEditorToolsMergeSpeakerIdentitiesIntoTarget() { await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync(); var target = await fixture.AddIdentityAsync("Sabrina", aliases: ["Sabi"], sample: [1]); var source = await fixture.AddIdentityAsync("Sabine", aliases: ["Guest Name"], candidates: ["Guest-01"], sample: [2, 3]); var tools = fixture.CreateTools(); var result = await tools.MergeIdentities(target.Id, source.Id); Assert.Contains("\"canonicalName\": \"Sabrina\"", result); var saved = await fixture.LoadIdentityAsync(target.Id); Assert.Equal(["Guest Name", "Guest-01", "Sabi", "Sabine"], saved.Aliases.Select(alias => alias.Name).Order()); var snippets = saved.Snippets.Select(sample => sample.WavBytes).OrderBy(bytes => bytes.Length).ToArray(); Assert.Equal([1], snippets[0]); Assert.Equal([2, 3], snippets[1]); Assert.False(await fixture.Context.SpeakerIdentities.AnyAsync(identity => identity.Id == source.Id)); } [Fact] public async Task RulesEditorToolsListReadQueueAndDeleteSpeakerSamples() { await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync(); var identity = await fixture.AddIdentityAsync("Sabrina", sample: [4, 5, 6]); var sampleId = identity.Snippets.Single().Id; fixture.Context.SpeakerSnippets.Add(new SpeakerSnippet { SpeakerIdentityId = identity.Id, WavBytes = [7, 8, 9], CreatedAt = DateTimeOffset.UtcNow }); await fixture.Context.SaveChangesAsync(); var queue = new CapturingSamplePlaybackQueue(); var tools = fixture.CreateTools(queue); var listResult = await tools.ListIdentitySamples(identity.Id); var readResult = await tools.ReadIdentitySample(sampleId); var playResult = await tools.QueuePlayIdentitySample(sampleId); var deleteResult = await tools.DeleteIdentitySample(sampleId); Assert.Contains($"\"id\": {sampleId}", listResult); Assert.Contains(Convert.ToBase64String([4, 5, 6]), readResult); Assert.Equal($"Queued sample {sampleId}.", playResult); var queued = queue.Queued.Single(); Assert.Equal(sampleId, queued.SampleId); Assert.Equal([4, 5, 6], queued.WavBytes); Assert.Equal($"Deleted sample {sampleId}.", deleteResult); var remaining = await fixture.Context.SpeakerSnippets.ToListAsync(); Assert.Single(remaining); Assert.NotEqual(sampleId, remaining.Single().Id); } [Fact] public async Task RulesEditorToolsRefusesDeletingLastSpeakerIdentitySample() { await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync(); var identity = await fixture.AddIdentityAsync("Sabrina", sample: [4, 5, 6]); var sampleId = identity.Snippets.Single().Id; var tools = fixture.CreateTools(); var result = await tools.DeleteIdentitySample(sampleId); Assert.Equal( $"Refused: sample {sampleId} is the last sample for identity {identity.Id}. Delete the identity instead.", result); Assert.Single(await fixture.Context.SpeakerSnippets.ToListAsync()); } [Fact] public async Task ViewModelShowsUserMessageThinkingStateAndFinalAgentMessage() { var pipeline = new BlockingRulesEditorPipeline("Updated rules."); var viewModel = new WorkflowRulesEditorChatViewModel(pipeline) { Draft = "Rename ABS Daily" }; var sendTask = viewModel.SendAsync(); await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); Assert.True(viewModel.IsThinking); Assert.Equal("", viewModel.Draft); Assert.Equal( [new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "Rename ABS Daily")], viewModel.Messages.Select(item => item.Message).OfType().ToArray()); pipeline.Release.SetResult(); await sendTask.WaitAsync(TimeSpan.FromSeconds(2)); Assert.False(viewModel.IsThinking); AssertCompletedActivity(viewModel, ["Thinking..."], "Updated rules."); } [Fact] public async Task ViewModelShowsAgentErrorWhenRequestTimesOut() { var viewModel = new WorkflowRulesEditorChatViewModel( new ThrowingRulesEditorPipeline(new TaskCanceledException("The request timed out."))) { Draft = "check logs" }; await viewModel.SendAsync(); Assert.False(viewModel.IsThinking); Assert.Equal("", viewModel.Draft); Assert.Equal(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "check logs"), viewModel.Messages[0].Message); AssertCompletedActivity( viewModel, ["Thinking..."], "Meeting Summary Agent failed: The request timed out."); } [Fact] public async Task ViewModelShowsReconnectStatusReportedByPipeline() { var pipeline = new ReportingRulesEditorPipeline( "Updated rules.", WorkflowRulesEditorActivityUpdate.Status("Reconnecting...")); var viewModel = new WorkflowRulesEditorChatViewModel(pipeline) { Draft = "check logs" }; var sendTask = viewModel.SendAsync(); await pipeline.Reported.Task.WaitAsync(TimeSpan.FromSeconds(2)); Assert.True(viewModel.IsThinking); Assert.Equal("Reconnecting...", viewModel.ActivityMessage); pipeline.Release.SetResult(); await sendTask.WaitAsync(TimeSpan.FromSeconds(2)); Assert.False(viewModel.IsThinking); Assert.Equal("Thinking...", viewModel.ActivityMessage); AssertCompletedActivity(viewModel, ["Thinking...", "Reconnecting..."], "Updated rules."); } [Fact] public async Task ViewModelShowsToolCallsAbovePersistentThinkingLine() { var pipeline = new ReportingRulesEditorPipeline( "Updated rules.", WorkflowRulesEditorActivityUpdate.ToolCall("read_logs")); var viewModel = new WorkflowRulesEditorChatViewModel(pipeline) { Draft = "check logs" }; var sendTask = viewModel.SendAsync(); await pipeline.Reported.Task.WaitAsync(TimeSpan.FromSeconds(2)); Assert.True(viewModel.IsThinking); Assert.Equal("Thinking...", viewModel.ActivityMessage); Assert.Equal(["Called tool: read_logs"], viewModel.ActivityMessages.ToArray()); pipeline.Release.SetResult(); await sendTask.WaitAsync(TimeSpan.FromSeconds(2)); Assert.False(viewModel.IsThinking); Assert.Empty(viewModel.ActivityMessages); AssertCompletedActivity(viewModel, ["Thinking...", "Called tool: read_logs"], "Updated rules."); } [Fact] public async Task ViewModelShowsVisibleThinkingOutputInActivityExpander() { var pipeline = new ReportingRulesEditorPipeline( "Updated rules.", WorkflowRulesEditorActivityUpdate.Thinking("Checked the recent logs."), WorkflowRulesEditorActivityUpdate.ToolCall("read_logs"), WorkflowRulesEditorActivityUpdate.Thinking("Matched the rule to the user request.")); var viewModel = new WorkflowRulesEditorChatViewModel(pipeline) { Draft = "check logs" }; var sendTask = viewModel.SendAsync(); await pipeline.Reported.Task.WaitAsync(TimeSpan.FromSeconds(2)); Assert.True(viewModel.IsThinking); Assert.Equal("Thinking...", viewModel.ActivityMessage); Assert.Equal( [ "Checked the recent logs.", "Called tool: read_logs", "Matched the rule to the user request." ], viewModel.ActivityMessages.ToArray()); pipeline.Release.SetResult(); await sendTask.WaitAsync(TimeSpan.FromSeconds(2)); Assert.False(viewModel.IsThinking); Assert.Empty(viewModel.ActivityMessages); AssertCompletedActivity( viewModel, [ "Checked the recent logs.", "Called tool: read_logs", "Matched the rule to the user request." ], "Updated rules."); } [Fact] public async Task ViewModelMarshalsBackgroundActivityUpdatesToCapturedContext() { var previousContext = SynchronizationContext.Current; var context = new RecordingSynchronizationContext(); var pipeline = new BackgroundReportingRulesEditorPipeline( "Updated rules.", WorkflowRulesEditorActivityUpdate.Thinking("Checked the recent logs.")); var viewModel = new WorkflowRulesEditorChatViewModel(pipeline) { Draft = "check logs" }; SynchronizationContext.SetSynchronizationContext(context); var sendTask = viewModel.SendAsync(); SynchronizationContext.SetSynchronizationContext(previousContext); await pipeline.Reported.Task.WaitAsync(TimeSpan.FromSeconds(2)); Assert.True(viewModel.IsThinking); Assert.Equal(["Checked the recent logs."], viewModel.ActivityMessages.ToArray()); Assert.Equal(1, context.SendCount); pipeline.Release.SetResult(); await sendTask.WaitAsync(TimeSpan.FromSeconds(2)); AssertCompletedActivity(viewModel, ["Checked the recent logs."], "Updated rules."); } [Fact] public async Task ViewModelKeepsActivityItemsOutOfModelConversation() { var pipeline = new BlockingRulesEditorPipeline("Updated rules."); var viewModel = new WorkflowRulesEditorChatViewModel(pipeline) { Draft = "first" }; var firstSend = viewModel.SendAsync(); await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); pipeline.Release.SetResult(); await firstSend.WaitAsync(TimeSpan.FromSeconds(2)); pipeline.Reset("Second response."); viewModel.Draft = "second"; var secondSend = viewModel.SendAsync(); await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); Assert.Equal( [ new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "first"), new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, "Updated rules.") ], pipeline.LastConversation); pipeline.Release.SetResult(); await secondSend.WaitAsync(TimeSpan.FromSeconds(2)); } private static void AssertCompletedActivity( WorkflowRulesEditorChatViewModel viewModel, IReadOnlyList expectedActivityLines, string expectedAgentMessage) { Assert.Equal(3, viewModel.Messages.Count); Assert.Equal(WorkflowRulesEditorConversationItemKind.Activity, viewModel.Messages[1].Kind); Assert.StartsWith("Worked for ", viewModel.Messages[1].Content); Assert.Equal(expectedActivityLines, viewModel.Messages[1].ActivityLines); Assert.Equal(WorkflowRulesEditorConversationItemKind.Message, viewModel.Messages[2].Kind); Assert.Equal(WorkflowRulesEditorChatRole.Agent, viewModel.Messages[2].Message?.Role); Assert.Equal(expectedAgentMessage, viewModel.Messages[2].Message?.Content); } [Theory] [InlineData(0, 500, 0, true)] [InlineData(0, 500, 400, true)] [InlineData(492, 500, 1000, true)] [InlineData(300, 500, 1000, false)] public void ScrollPolicyAutoScrollsOnlyWhenAlreadyNearBottom( double verticalOffset, double viewportHeight, double contentHeight, bool expected) { Assert.Equal( expected, WorkflowRulesEditorScrollPolicy.ShouldAutoScroll( verticalOffset, viewportHeight, contentHeight)); } [Fact] public void ScrollPolicyAutoScrollsWhenPreviousContentFitBeforeNewCardOverflows() { Assert.True(WorkflowRulesEditorScrollPolicy.ShouldAutoScroll( verticalOffset: 0, viewportHeight: 500, previousContentHeight: 500)); } [Fact] public void ScrollPolicyComputesNonNegativeBottomOffset() { Assert.Equal(500, WorkflowRulesEditorScrollPolicy.GetBottomOffset(500, 1000)); Assert.Equal(0, WorkflowRulesEditorScrollPolicy.GetBottomOffset(500, 400)); } [Fact] public void MarkdownParserRecognizesRequestedInlineStyles() { var inlines = WorkflowRulesEditorMarkdown.ParseInline( "Normal *italic* **bold** ***both*** ~~gone~~ [docs](https://example.test) ![logo](C:/tmp/logo.png) and `code`."); Assert.Equal( [ new WorkflowRulesEditorMarkdownInline("Normal ", WorkflowRulesEditorMarkdownInlineStyle.Normal), new WorkflowRulesEditorMarkdownInline("italic", WorkflowRulesEditorMarkdownInlineStyle.Italic), new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal), new WorkflowRulesEditorMarkdownInline("bold", WorkflowRulesEditorMarkdownInlineStyle.Bold), new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal), new WorkflowRulesEditorMarkdownInline("both", WorkflowRulesEditorMarkdownInlineStyle.BoldItalic), new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal), new WorkflowRulesEditorMarkdownInline("gone", WorkflowRulesEditorMarkdownInlineStyle.Strikethrough), new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal), new WorkflowRulesEditorMarkdownInline( "docs", WorkflowRulesEditorMarkdownInlineStyle.Link, "https://example.test"), new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal), new WorkflowRulesEditorMarkdownInline( "https://auto.test", WorkflowRulesEditorMarkdownInlineStyle.Link, "https://auto.test"), new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal), new WorkflowRulesEditorMarkdownInline( "test@example.com", WorkflowRulesEditorMarkdownInlineStyle.Link, "mailto:test@example.com"), new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal), new WorkflowRulesEditorMarkdownInline( "logo", WorkflowRulesEditorMarkdownInlineStyle.Image, "C:/tmp/logo.png"), new WorkflowRulesEditorMarkdownInline(" and ", WorkflowRulesEditorMarkdownInlineStyle.Normal), new WorkflowRulesEditorMarkdownInline("code", WorkflowRulesEditorMarkdownInlineStyle.Code), new WorkflowRulesEditorMarkdownInline(".", WorkflowRulesEditorMarkdownInlineStyle.Normal) ], inlines); } [Fact] public void MarkdownParserRecognizesHeadingsBlockquotesAndCheckboxes() { var blocks = WorkflowRulesEditorMarkdown.Parse(""" # Main ## Detail > quoted **line** > second - [x] done - [ ] open """); Assert.Collection( blocks, block => { var heading = Assert.IsType(block); Assert.Equal(1, heading.Level); Assert.Equal("# Main", Assert.Single(heading.Inlines).Text); }, block => { var heading = Assert.IsType(block); Assert.Equal(2, heading.Level); Assert.Equal("## Detail", Assert.Single(heading.Inlines).Text); }, block => { var quote = Assert.IsType(block); Assert.Contains(quote.Inlines, inline => inline.Text.Contains("quoted", StringComparison.Ordinal)); Assert.Contains(quote.Inlines, inline => inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold); }, block => { var tasks = Assert.IsType(block); Assert.Collection( tasks.Items, item => { Assert.True(item.IsChecked); Assert.Equal("done", Assert.Single(item.Inlines).Text); }, item => { Assert.False(item.IsChecked); Assert.Equal("open", Assert.Single(item.Inlines).Text); }); }); } [Fact] public void MarkdownParserRecognizesStandaloneImageEmbeds() { var blocks = WorkflowRulesEditorMarkdown.Parse(""" Before ![Architecture diagram](C:/tmp/architecture.png) After """); Assert.Collection( blocks, block => Assert.IsType(block), block => { var image = Assert.IsType(block); Assert.Equal("Architecture diagram", image.AltText); Assert.Equal("C:/tmp/architecture.png", image.Target); }, block => Assert.IsType(block)); } [Fact] public void MarkdownLinkResolverFindsScreenshotAttachmentsUnderArtifactRoots() { var temp = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); try { var contextRoot = Path.Combine(temp, "Meetings", "Assistant Context"); var attachmentPath = Path.Combine( contextRoot, "2026", "06", "Attachments", "20260601-120000-001.png"); Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!); File.WriteAllBytes(attachmentPath, [1, 2, 3]); var resolver = new WorkflowRulesEditorMarkdownLinkResolver([contextRoot], [contextRoot]); Assert.True(resolver.TryResolveImageUri("Attachments/20260601-120000-001.png", out var uri)); Assert.Equal(new Uri(attachmentPath), uri); Assert.Equal(attachmentPath, resolver.ResolveOpenTarget("Attachments/20260601-120000-001.png")); } finally { if (Directory.Exists(temp)) { Directory.Delete(temp, recursive: true); } } } [Fact] public void MarkdownLinkResolverDoesNotCacheMissingFiles() { var temp = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); try { var contextRoot = Path.Combine(temp, "Meetings", "Assistant Context"); var attachmentPath = Path.Combine(contextRoot, "Attachments", "late.png"); var resolver = new WorkflowRulesEditorMarkdownLinkResolver([contextRoot], [contextRoot]); Assert.False(resolver.TryResolveImageUri("Attachments/late.png", out _)); Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!); File.WriteAllBytes(attachmentPath, [1, 2, 3]); Assert.True(resolver.TryResolveImageUri("Attachments/late.png", out var uri)); Assert.Equal(new Uri(attachmentPath), uri); } finally { if (Directory.Exists(temp)) { Directory.Delete(temp, recursive: true); } } } [Fact] public void MarkdownParserPreservesParagraphLineBreaks() { var blocks = WorkflowRulesEditorMarkdown.Parse(""" First line Second line with `code` """); var paragraph = Assert.IsType(Assert.Single(blocks)); Assert.Contains(paragraph.Inlines, inline => inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Normal && inline.Text.Contains('\n')); Assert.Contains(paragraph.Inlines, inline => inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Code && inline.Text == "code"); } [Fact] public void MarkdownParserRecognizesPipeTables() { var blocks = WorkflowRulesEditorMarkdown.Parse(""" Current identities: | ID | Name | Samples | |----|------|---------| | 1 | Manuel | 2 | | 2 | Sabrina | 1 | """); Assert.Collection( blocks, block => Assert.IsType(block), block => { var table = Assert.IsType(block); Assert.Equal(["ID", "Name", "Samples"], table.Header); Assert.Equal(["1", "Manuel", "2"], table.Rows[0]); Assert.Equal(["2", "Sabrina", "1"], table.Rows[1]); }); } [Fact] public void MarkdownParserRecognizesFencedCodeBlocks() { var blocks = WorkflowRulesEditorMarkdown.Parse(""" Before ```yaml rules: - name: example ``` After **bold** """); Assert.Collection( blocks, block => Assert.IsType(block), block => { var code = Assert.IsType(block); Assert.Equal("rules:" + Environment.NewLine + "- name: example", code.Text); }, block => { var paragraph = Assert.IsType(block); Assert.Contains(paragraph.Inlines, inline => inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold); }); } private sealed class BlockingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline { private string response; public BlockingRulesEditorPipeline(string response) { this.response = response; } public TaskCompletionSource Started { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource Release { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public IReadOnlyList LastConversation { get; private set; } = []; public void Reset(string nextResponse) { response = nextResponse; Started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); Release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); } public async Task SendAsync( IReadOnlyList conversation, string userMessage, CancellationToken cancellationToken, Action? activityChanged = null) { LastConversation = conversation; Started.SetResult(); await Release.Task.WaitAsync(cancellationToken); return new WorkflowRulesEditorChatResult(response); } } private sealed class ThrowingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline { private readonly Exception exception; public ThrowingRulesEditorPipeline(Exception exception) { this.exception = exception; } public Task SendAsync( IReadOnlyList conversation, string userMessage, CancellationToken cancellationToken, Action? activityChanged = null) { throw exception; } } private sealed class ReportingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline { private readonly string response; private readonly IReadOnlyList updates; public ReportingRulesEditorPipeline( string response, params WorkflowRulesEditorActivityUpdate[] updates) { this.response = response; this.updates = updates; } public TaskCompletionSource Reported { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public async Task SendAsync( IReadOnlyList conversation, string userMessage, CancellationToken cancellationToken, Action? activityChanged = null) { foreach (var update in updates) { activityChanged?.Invoke(update); } Reported.SetResult(); await Release.Task.WaitAsync(cancellationToken); return new WorkflowRulesEditorChatResult(response); } } private sealed class BackgroundReportingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline { private readonly string response; private readonly WorkflowRulesEditorActivityUpdate update; public BackgroundReportingRulesEditorPipeline( string response, WorkflowRulesEditorActivityUpdate update) { this.response = response; this.update = update; } public TaskCompletionSource Reported { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public async Task SendAsync( IReadOnlyList conversation, string userMessage, CancellationToken cancellationToken, Action? activityChanged = null) { await Task.Run(() => activityChanged?.Invoke(update), cancellationToken); Reported.SetResult(); await Release.Task.WaitAsync(cancellationToken); return new WorkflowRulesEditorChatResult(response); } } private sealed class RecordingSynchronizationContext : SynchronizationContext { public int SendCount { get; private set; } public override void Send(SendOrPostCallback callback, object? state) { SendCount++; callback(state); } } private sealed class CapturingLogger : ILogger { public List Messages { get; } = []; public IDisposable? BeginScope(TState state) where TState : notnull { return null; } 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 static async Task CreateRulesEditorFixtureAsync( ILogger? logger = null, string initialContent = "rules: []") { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var rulesPath = Path.Combine(root, "rules.yaml"); await File.WriteAllTextAsync(rulesPath, initialContent); var options = new MeetingAssistantOptions { Automation = new AutomationOptions { RulesPath = rulesPath } }; return new RulesEditorFixture( rulesPath, options, new WorkflowRulesEditorTools(options, logger: logger)); } private sealed record RulesEditorFixture( string RulesPath, MeetingAssistantOptions Options, WorkflowRulesEditorTools Tools); private sealed class WorkflowRulesEditorIdentityFixture : IAsyncDisposable { private readonly string tempDirectory; private readonly string dbPath; private WorkflowRulesEditorIdentityFixture( string tempDirectory, string dbPath, SpeakerIdentityDbContext context) { this.tempDirectory = tempDirectory; this.dbPath = dbPath; Context = context; } public SpeakerIdentityDbContext Context { get; } public static async Task CreateAsync() { var tempDirectory = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDirectory); var dbPath = Path.Combine(tempDirectory, "speaker-identities.db"); var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder() .UseSqlite($"Data Source={dbPath};Pooling=False") .Options); await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None); return new WorkflowRulesEditorIdentityFixture(tempDirectory, dbPath, context); } public WorkflowRulesEditorTools CreateTools( IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null) { return new WorkflowRulesEditorTools( new MeetingAssistantOptions(), new TestSpeakerIdentityDbContextFactory(dbPath), samplePlaybackQueue); } public async Task AddIdentityAsync( string canonicalName, IReadOnlyList? aliases = null, IReadOnlyList? candidates = null, byte[]? sample = null) { var now = DateTimeOffset.UtcNow; var identity = new SpeakerIdentity { CanonicalName = canonicalName, CreatedAt = now, UpdatedAt = now, Aliases = aliases?.Select(alias => new SpeakerAlias { Name = alias }).ToList() ?? [], CandidateNames = candidates?.Select(candidate => new SpeakerCandidateName { Name = candidate }).ToList() ?? [], Snippets = sample is null ? [] : [ new SpeakerSnippet { WavBytes = sample, CreatedAt = now } ] }; Context.SpeakerIdentities.Add(identity); await Context.SaveChangesAsync(); return identity; } public Task LoadIdentityAsync(int id) { Context.ChangeTracker.Clear(); return Context.SpeakerIdentities .Include(identity => identity.Aliases) .Include(identity => identity.CandidateNames) .Include(identity => identity.Snippets) .SingleAsync(identity => identity.Id == id); } public async ValueTask DisposeAsync() { await Context.DisposeAsync(); if (Directory.Exists(tempDirectory)) { Directory.Delete(tempDirectory, recursive: true); } } } private sealed class TestSpeakerIdentityDbContextFactory : IDbContextFactory { private readonly string dbPath; public TestSpeakerIdentityDbContextFactory(string dbPath) { this.dbPath = dbPath; } public SpeakerIdentityDbContext CreateDbContext() { return new SpeakerIdentityDbContext(new DbContextOptionsBuilder() .UseSqlite($"Data Source={dbPath};Pooling=False") .Options); } } private sealed class CapturingSamplePlaybackQueue : IWorkflowRulesEditorSamplePlaybackQueue { public List<(int SampleId, byte[] WavBytes)> Queued { get; } = []; public Task QueueAsync(int sampleId, byte[] wavBytes, CancellationToken cancellationToken = default) { Queued.Add((sampleId, wavBytes)); return Task.FromResult($"Queued sample {sampleId}."); } } }