Generalize settings and logs assistant
PR and Push Build/Test / build-and-test (push) Successful in 16m43s

This commit is contained in:
2026-05-30 12:57:51 +02:00
parent 740f93f185
commit 250d3b7a1e
32 changed files with 2219 additions and 539 deletions
@@ -50,7 +50,7 @@ public sealed class MeetingSummaryToolTests
Assert.False(tools.SummaryWasWritten);
var result = await tools.WriteSummary("# Summary\n\n- Done.", "Done items");
Assert.Equal(artifacts.SummaryPath, result);
Assert.Equal(Path.GetFileName(artifacts.SummaryPath), result);
Assert.True(tools.SummaryWasWritten);
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
Assert.Contains("title: Meeting", summary);
@@ -478,6 +478,22 @@ public sealed class MeetingSummaryToolTests
Assert.Equal("", await tools.ReadTranscript(from: 3, to: 2));
}
[Fact]
public async Task ReadToolsSupportTail()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var artifacts = new MeetingSessionArtifacts(
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.TranscriptPath)!);
await File.WriteAllTextAsync(artifacts.TranscriptPath, "one\ntwo\nthree");
var tools = new MeetingSummaryTools(artifacts);
Assert.Equal("two\nthree", await tools.ReadTranscript(tail: 2));
}
[Fact]
public async Task ToolsWriteAssistantContextBodyWithLineModes()
{
@@ -121,6 +121,41 @@ public sealed class ProjectKnowledgeToolTests
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1, to: 1, replace_file: true));
}
[Fact]
public async Task SearchCanBeLimitedByProjectFilePattern()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
var projectRoot = Path.Combine(projectsRoot, "MeetingAssistant");
Directory.CreateDirectory(projectRoot);
await File.WriteAllTextAsync(Path.Combine(projectRoot, "PROJECT.md"), "durable needle");
await File.WriteAllTextAsync(Path.Combine(projectRoot, "scratch.txt"), "scratch needle");
var artifacts = CreateArtifacts(root);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
projects:
- MeetingAssistant
---
""");
var tools = new MeetingSummaryTools(
artifacts,
new MeetingAssistantOptions
{
Vault =
{
ProjectsFolder = projectsRoot
}
});
var search = await tools.Search("needle", file_pattern: "*.md");
Assert.Contains("PROJECT.md:1 durable needle", search);
Assert.DoesNotContain("scratch.txt", search);
}
private static MeetingSessionArtifacts CreateArtifacts(string root)
{
return new MeetingSessionArtifacts(
@@ -145,6 +145,40 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StopTreatsRunsShorterThanConfiguredMinimumAsAbort()
{
var audioSource = new ControlledAudioSource();
var artifactCleaner = new CapturingMeetingRunArtifactCleaner();
var summaryPipeline = new CapturingMeetingSummaryPipeline();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
summaryPipeline,
Options.Create(new MeetingAssistantOptions
{
Recording =
{
MinimumCompletedMeetingDuration = TimeSpan.FromMinutes(1)
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
artifactCleaner: artifactCleaner);
var started = await coordinator.StartAsync(CancellationToken.None);
var stopped = await coordinator.StopAsync(CancellationToken.None);
Assert.False(stopped.IsRecording);
Assert.Null(stopped.MeetingNotePath);
Assert.False(summaryPipeline.WasRun);
Assert.Equal(started.SummaryPath, artifactCleaner.DeletedArtifacts?.SummaryPath);
}
[Fact]
public async Task StartUsesCurrentOutlookMeetingMetadataWhenAvailable()
{
@@ -2240,6 +2274,8 @@ public sealed class RecordingCoordinatorTests
public MeetingAssistantOptions? Options { get; private set; }
public bool WasRun => Artifacts is not null;
public Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
@@ -2262,6 +2298,23 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class CapturingMeetingRunArtifactCleaner : IMeetingRunArtifactCleaner
{
public MeetingSessionArtifacts? DeletedArtifacts { get; private set; }
public MeetingAssistantOptions? Options { get; private set; }
public Task DeleteRunArtifactsAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
DeletedArtifacts = artifacts;
Options = options;
return Task.CompletedTask;
}
}
private sealed class RecordingSummaryPipeline : IMeetingSummaryPipeline
{
public List<MeetingSessionArtifacts> ArtifactHistory { get; } = [];
+1 -1
View File
@@ -16,7 +16,7 @@ public sealed class TaskbarIconTests
Assert.Equal(RecordingProcessState.Idle, menu.State);
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.EditRules &&
item.Text == "Edit rules and identities");
item.Text == "Settings and logs");
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.StartRecording &&
item.ProfileName == "default" &&
@@ -1,6 +1,8 @@
using MeetingAssistant.Workflow;
using MeetingAssistant.Logging;
using MeetingAssistant.Speakers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using System.Text.Json;
@@ -82,6 +84,239 @@ public sealed class WorkflowRulesEditorTests
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);
var tail = await tools.ReadLogs(tail: 2);
var range = await tools.ReadLogs(from: 2, to: 2);
var search = await tools.SearchLogs("needle");
Assert.DoesNotContain("first line", tail);
Assert.Contains("second needle", tail);
Assert.Equal("second needle", range.Trim());
Assert.Contains("meeting-assistant.log:2 second needle", search);
Assert.Contains("meeting-assistant.log.1: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"));
}
[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 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()
{
@@ -157,6 +392,9 @@ public sealed class WorkflowRulesEditorTests
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);
}
[Fact]