Public Access
1427 lines
59 KiB
C#
1427 lines
59 KiB
C#
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",
|
|
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",
|
|
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.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"));
|
|
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<string, string?>
|
|
{
|
|
["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 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<FileMeetingWorkflowRulesProvider>.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<WorkflowRulesEditorInstructionBuilder>.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 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.ToArray());
|
|
|
|
pipeline.Release.SetResult();
|
|
await sendTask.WaitAsync(TimeSpan.FromSeconds(2));
|
|
|
|
Assert.False(viewModel.IsThinking);
|
|
Assert.Equal(2, viewModel.Messages.Count);
|
|
Assert.Equal(WorkflowRulesEditorChatRole.Agent, viewModel.Messages[1].Role);
|
|
Assert.Equal("Updated rules.", viewModel.Messages[1].Content);
|
|
}
|
|
|
|
[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"),
|
|
new WorkflowRulesEditorChatMessage(
|
|
WorkflowRulesEditorChatRole.Agent,
|
|
"Settings and logs failed: The request timed out.")
|
|
],
|
|
viewModel.Messages.ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ViewModelShowsReconnectStatusReportedByPipeline()
|
|
{
|
|
var pipeline = new StatusReportingRulesEditorPipeline("Updated rules.");
|
|
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);
|
|
Assert.Equal("Updated rules.", viewModel.Messages[1].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) <https://auto.test> <test@example.com>  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<WorkflowRulesEditorMarkdownHeading>(block);
|
|
Assert.Equal(1, heading.Level);
|
|
Assert.Equal("# Main", Assert.Single(heading.Inlines).Text);
|
|
},
|
|
block =>
|
|
{
|
|
var heading = Assert.IsType<WorkflowRulesEditorMarkdownHeading>(block);
|
|
Assert.Equal(2, heading.Level);
|
|
Assert.Equal("## Detail", Assert.Single(heading.Inlines).Text);
|
|
},
|
|
block =>
|
|
{
|
|
var quote = Assert.IsType<WorkflowRulesEditorMarkdownBlockQuote>(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<WorkflowRulesEditorMarkdownTaskList>(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
|
|
|
|

|
|
|
|
After
|
|
""");
|
|
|
|
Assert.Collection(
|
|
blocks,
|
|
block => Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(block),
|
|
block =>
|
|
{
|
|
var image = Assert.IsType<WorkflowRulesEditorMarkdownImage>(block);
|
|
Assert.Equal("Architecture diagram", image.AltText);
|
|
Assert.Equal("C:/tmp/architecture.png", image.Target);
|
|
},
|
|
block => Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(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<WorkflowRulesEditorMarkdownParagraph>(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<WorkflowRulesEditorMarkdownParagraph>(block),
|
|
block =>
|
|
{
|
|
var table = Assert.IsType<WorkflowRulesEditorMarkdownTable>(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<WorkflowRulesEditorMarkdownParagraph>(block),
|
|
block =>
|
|
{
|
|
var code = Assert.IsType<WorkflowRulesEditorMarkdownCodeBlock>(block);
|
|
Assert.Equal("rules:" + Environment.NewLine + "- name: example", code.Text);
|
|
},
|
|
block =>
|
|
{
|
|
var paragraph = Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(block);
|
|
Assert.Contains(paragraph.Inlines, inline => inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold);
|
|
});
|
|
}
|
|
|
|
private sealed class BlockingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
|
|
{
|
|
private readonly string response;
|
|
|
|
public BlockingRulesEditorPipeline(string response)
|
|
{
|
|
this.response = response;
|
|
}
|
|
|
|
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
public async Task<WorkflowRulesEditorChatResult> SendAsync(
|
|
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
|
string userMessage,
|
|
CancellationToken cancellationToken,
|
|
Action<string>? statusChanged = null)
|
|
{
|
|
Started.SetResult();
|
|
await Release.Task.WaitAsync(cancellationToken);
|
|
return new WorkflowRulesEditorChatResult(
|
|
response,
|
|
conversation
|
|
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage))
|
|
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, response))
|
|
.ToList());
|
|
}
|
|
}
|
|
|
|
private sealed class ThrowingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
|
|
{
|
|
private readonly Exception exception;
|
|
|
|
public ThrowingRulesEditorPipeline(Exception exception)
|
|
{
|
|
this.exception = exception;
|
|
}
|
|
|
|
public Task<WorkflowRulesEditorChatResult> SendAsync(
|
|
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
|
string userMessage,
|
|
CancellationToken cancellationToken,
|
|
Action<string>? statusChanged = null)
|
|
{
|
|
throw exception;
|
|
}
|
|
}
|
|
|
|
private sealed class StatusReportingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
|
|
{
|
|
private readonly string response;
|
|
|
|
public StatusReportingRulesEditorPipeline(string response)
|
|
{
|
|
this.response = response;
|
|
}
|
|
|
|
public TaskCompletionSource Reported { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
public async Task<WorkflowRulesEditorChatResult> SendAsync(
|
|
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
|
string userMessage,
|
|
CancellationToken cancellationToken,
|
|
Action<string>? statusChanged = null)
|
|
{
|
|
statusChanged?.Invoke("Reconnecting...");
|
|
Reported.SetResult();
|
|
await Release.Task.WaitAsync(cancellationToken);
|
|
return new WorkflowRulesEditorChatResult(
|
|
response,
|
|
conversation
|
|
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage))
|
|
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, response))
|
|
.ToList());
|
|
}
|
|
}
|
|
|
|
private sealed class CapturingLogger : ILogger
|
|
{
|
|
public List<string> Messages { get; } = [];
|
|
|
|
public IDisposable? BeginScope<TState>(TState state)
|
|
where TState : notnull
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public bool IsEnabled(LogLevel logLevel)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public void Log<TState>(
|
|
LogLevel logLevel,
|
|
EventId eventId,
|
|
TState state,
|
|
Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
Messages.Add(formatter(state, exception));
|
|
}
|
|
}
|
|
|
|
private static async Task<RulesEditorFixture> 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<WorkflowRulesEditorIdentityFixture> 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<SpeakerIdentityDbContext>()
|
|
.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<SpeakerIdentity> AddIdentityAsync(
|
|
string canonicalName,
|
|
IReadOnlyList<string>? aliases = null,
|
|
IReadOnlyList<string>? 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<SpeakerIdentity> 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<SpeakerIdentityDbContext>
|
|
{
|
|
private readonly string dbPath;
|
|
|
|
public TestSpeakerIdentityDbContextFactory(string dbPath)
|
|
{
|
|
this.dbPath = dbPath;
|
|
}
|
|
|
|
public SpeakerIdentityDbContext CreateDbContext()
|
|
{
|
|
return new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
|
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
|
.Options);
|
|
}
|
|
}
|
|
|
|
private sealed class CapturingSamplePlaybackQueue : IWorkflowRulesEditorSamplePlaybackQueue
|
|
{
|
|
public List<(int SampleId, byte[] WavBytes)> Queued { get; } = [];
|
|
|
|
public Task<string> QueueAsync(int sampleId, byte[] wavBytes, CancellationToken cancellationToken = default)
|
|
{
|
|
Queued.Add((sampleId, wavBytes));
|
|
return Task.FromResult($"Queued sample {sampleId}.");
|
|
}
|
|
}
|
|
}
|