using System.Threading.Channels; using MeetingAssistant.MeetingNotes; using MeetingAssistant.Notifications; using MeetingAssistant.Summary; using Microsoft.Extensions.Logging.Abstractions; using MeetingAssistant.Workflow; namespace MeetingAssistant.Tests; public sealed class SummaryAgentRequestTests { [Theory] [InlineData("denied")] [InlineData("expired")] [InlineData("shutdown")] public async Task WorkflowDenialExpirationAndShutdownRemovePromptWithoutStartingAgent(string outcome) { var prompts = new ControlledPrompts(); var editor = new RecordingEditor(); var approvals = new AgentApprovalService(prompts, NullLogger.Instance, outcome == "expired" ? TimeSpan.FromMilliseconds(100) : TimeSpan.FromMinutes(10)); using var requests = new WorkflowChangeRequestService(approvals, editor, NullLogger.Instance); await requests.StartAsync(CancellationToken.None); try { Assert.StartsWith("Requested", requests.Request("Add association rule", "Given Alpha, then associate Alpha.", "meeting.md")); var prompt = await prompts.NextAsync(); if (outcome == "denied") prompt.Response.SetResult(false); if (outcome == "shutdown") await requests.StopAsync(CancellationToken.None); await prompt.Removed.Task.WaitAsync(TimeSpan.FromSeconds(2)); prompt.Response.TrySetResult(true); } finally { await requests.StopAsync(CancellationToken.None); } Assert.Empty(editor.Prompts); Assert.StartsWith("Refused", requests.Request("Too late", "Do something", "meeting.md")); } [Fact] public async Task InvalidRequestsAreRefusedBeforeNotification() { var fixture = await CreateAsync(); var original = await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath); var prompts = new ControlledPrompts(); var approvals = new AgentApprovalService(prompts, NullLogger.Instance); using var requests = new WorkflowChangeRequestService(approvals, new RecordingEditor(), NullLogger.Instance); var tools = new MeetingSummaryTools(fixture.Artifacts, fixture.Options, approvals: approvals, workflowRequests: requests); Assert.StartsWith("Refused", await tools.RequestProjectAssociation("../Alpha", "Relevant")); Assert.StartsWith("Refused", await tools.RequestProjectAssociation("Alpha", new string('a', 101))); Assert.StartsWith("Refused", await tools.RequestProjectAssociation("Alpha", "two\nlines")); Assert.StartsWith("Refused", await tools.RequestProjectAssociation("Alpha", "")); Assert.StartsWith("Refused", tools.RequestWorkflowChange(new string('a', 101), "Detailed scenario")); Assert.StartsWith("Refused", tools.RequestWorkflowChange("Intent", " ")); Assert.StartsWith("Refused", tools.RequestWorkflowChange("two\nlines", "Detailed scenario")); Assert.False(prompts.HasMore); Assert.Equal(original, await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath)); } [Fact] public async Task WorkflowRequestReturnsImmediatelyAndApprovalStartsItsDetailedPromptOnce() { var fixture = await CreateAsync(); var prompts = new ControlledPrompts(); var editor = new RecordingEditor(); var approvals = new AgentApprovalService(prompts, NullLogger.Instance); using var requests = new WorkflowChangeRequestService(approvals, editor, NullLogger.Instance); await requests.StartAsync(CancellationToken.None); try { const string detail = "Given a meeting with Alpha in its title, when metadata arrives, then add project Alpha."; var tools = new MeetingSummaryTools(fixture.Artifacts, fixture.Options, workflowRequests: requests); var result = tools.RequestWorkflowChange("Associate Alpha meetings automatically", detail); Assert.StartsWith("Requested", result); var prompt = await prompts.NextAsync(); Assert.Contains("Workflow change requested: Associate Alpha meetings automatically", prompt.Prompt.Title); Assert.Empty(editor.Prompts); await tools.WriteSummary("The summary can finish while approval is pending.", "Summary finished"); Assert.True(tools.SummaryWasWritten); prompt.Response.SetResult(true); Assert.Equal(detail, await editor.Started.Task.WaitAsync(TimeSpan.FromSeconds(2))); Assert.False(prompt.Response.TrySetResult(true)); Assert.Single(editor.Prompts); await prompt.Removed.Task.WaitAsync(TimeSpan.FromSeconds(2)); } finally { await requests.StopAsync(CancellationToken.None); } } [Theory] [InlineData("denied")] [InlineData("expired")] [InlineData("canceled")] [InlineData("removed")] public async Task UnapprovedOrStaleAssociationNeverChangesNoteOrProjectAccess(string outcome) { var fixture = await CreateAsync(); var original = await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath); var prompts = new ControlledPrompts(); var approvals = new AgentApprovalService(prompts, NullLogger.Instance, outcome == "expired" ? TimeSpan.FromMilliseconds(100) : TimeSpan.FromMinutes(10)); var tools = new MeetingSummaryTools(fixture.Artifacts, fixture.Options, approvals: approvals); using var cancellation = new CancellationTokenSource(); var request = tools.RequestProjectAssociation("Alpha", "Relevant project", cancellation.Token); var prompt = await prompts.NextAsync(); if (outcome == "denied") prompt.Response.SetResult(false); if (outcome == "canceled") await cancellation.CancelAsync(); if (outcome == "removed") { Directory.Delete(Path.Combine(fixture.Options.Vault.ProjectsFolder, "Alpha"), recursive: true); prompt.Response.SetResult(true); } if (outcome == "canceled") await Assert.ThrowsAnyAsync(() => request); else Assert.DoesNotContain("Approved", await request.WaitAsync(TimeSpan.FromSeconds(2))); await prompt.Removed.Task.WaitAsync(TimeSpan.FromSeconds(2)); prompt.Response.TrySetResult(true); Assert.Equal(original, await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath)); Assert.Equal("", await tools.ReadProjectFile("Alpha", "AGENTS.md")); } [Fact] public async Task AssociationWaitsForApprovalThenPreservesLatestNoteAndUnlocksProjectWithInstructions() { var fixture = await CreateAsync(); var prompts = new ControlledPrompts(); var approvals = new AgentApprovalService(prompts, NullLogger.Instance); var tools = new MeetingSummaryTools(fixture.Artifacts, fixture.Options, approvals: approvals); var request = tools.RequestProjectAssociation("alpha", "Transcript discusses the Alpha launch"); var prompt = await prompts.NextAsync(); Assert.Contains("Alpha Platform", prompt.Prompt.Message); Assert.Contains("Transcript discusses", prompt.Prompt.Message); Assert.Equal(TimeSpan.FromMinutes(10), prompt.Prompt.Timeout); Assert.False(request.IsCompleted); Assert.Equal("", await tools.ReadProjectFile("Alpha", "AGENTS.md")); Assert.StartsWith("Refused:", await tools.WriteProjectFile("Alpha", "notes.md", "unauthorized")); var latestNote = "---\ntitle: Edited by user\nprojects: Existing\ncustom: keep-me\n---\n\nUser's latest notes."; await File.WriteAllTextAsync(fixture.Artifacts.MeetingNotePath, latestNote); prompt.Response.SetResult(true); var result = await request.WaitAsync(TimeSpan.FromSeconds(2)); Assert.StartsWith("Approved", result); Assert.Contains("Alpha instructions", result); Assert.Contains("Alpha Platform", result); var note = await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath); Assert.Contains("Edited by user", note); Assert.Contains("custom: keep-me", note); Assert.Contains("User's latest notes.", note); Assert.Contains("Existing", note); Assert.Contains("Alpha", note); Assert.Empty(Directory.EnumerateFiles(Path.GetDirectoryName(fixture.Artifacts.MeetingNotePath)!, "*.tmp")); Assert.Equal("Alpha instructions", await tools.ReadProjectFile("Alpha", "AGENTS.md")); Assert.Equal("Alpha/notes.md", await tools.WriteProjectFile("Alpha", "notes.md", "approved")); Assert.Contains("notes.md:1 approved", await tools.Search("approved", ["Alpha"])); Assert.StartsWith("Approved", await tools.RequestProjectAssociation("Alpha", "Already associated")); Assert.False(prompts.HasMore); } private static async Task<(MeetingSessionArtifacts Artifacts, MeetingAssistantOptions Options)> CreateAsync() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var project = Path.Combine(root, "Projects", "Alpha"); Directory.CreateDirectory(project); var artifacts = new MeetingSessionArtifacts(Path.Combine(root, "meeting.md"), Path.Combine(root, "transcript.md"), Path.Combine(root, "context.md"), Path.Combine(root, "summary.md")); await File.WriteAllTextAsync(artifacts.MeetingNotePath, "---\ntitle: Planning\nprojects: []\n---\nUser notes."); await File.WriteAllTextAsync(Path.Combine(project, "PROJECT.md"), "---\nname: Alpha Platform\ndescription: Meeting automation\n---"); await File.WriteAllTextAsync(Path.Combine(project, "AGENTS.md"), "Alpha instructions"); return (artifacts, new MeetingAssistantOptions { Vault = { ProjectsFolder = Path.Combine(root, "Projects") } }); } private sealed class RecordingEditor : IWorkflowRulesEditorWindowService { public List Prompts { get; } = []; public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public void Show() { } public void ShowWithPrompt(string initialPrompt) { Prompts.Add(initialPrompt); Started.TrySetResult(initialPrompt); } } private sealed class ControlledPrompts : IAgentApprovalPromptService { private readonly Channel requests = Channel.CreateUnbounded(); public bool HasMore => requests.Reader.TryPeek(out _); public Task NextAsync() => requests.Reader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); public async Task ConfirmAsync(AgentApprovalPrompt prompt, CancellationToken cancellationToken) { var request = new PendingPrompt(prompt, cancellationToken); requests.Writer.TryWrite(request); try { return await request.Response.Task.WaitAsync(cancellationToken); } finally { request.Removed.TrySetResult(); } } } private sealed record PendingPrompt(AgentApprovalPrompt Prompt, CancellationToken Cancellation) { public TaskCompletionSource Response { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource Removed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); } }