Public Access
PR and Push Build/Test / build-and-test (push) Successful in 12m0s
Append successful offline recovery with a duplicate-warning marker and retain failed replay sources. Offer newly available microphones through confirmation notifications. Add project discovery metadata and blocking association approval, plus nonblocking workflow requests that start a separate settings-agent conversation. Include regression tests, OpenSpec changes, and verified local Release startup.
210 lines
11 KiB
C#
210 lines
11 KiB
C#
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<AgentApprovalService>.Instance,
|
|
outcome == "expired" ? TimeSpan.FromMilliseconds(100) : TimeSpan.FromMinutes(10));
|
|
using var requests = new WorkflowChangeRequestService(approvals, editor, NullLogger<WorkflowChangeRequestService>.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<AgentApprovalService>.Instance);
|
|
using var requests = new WorkflowChangeRequestService(approvals, new RecordingEditor(), NullLogger<WorkflowChangeRequestService>.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<AgentApprovalService>.Instance);
|
|
using var requests = new WorkflowChangeRequestService(approvals, editor, NullLogger<WorkflowChangeRequestService>.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<AgentApprovalService>.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<OperationCanceledException>(() => 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<AgentApprovalService>.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<string> Prompts { get; } = [];
|
|
public TaskCompletionSource<string> 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<PendingPrompt> requests = Channel.CreateUnbounded<PendingPrompt>();
|
|
public bool HasMore => requests.Reader.TryPeek(out _);
|
|
public Task<PendingPrompt> NextAsync() => requests.Reader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2));
|
|
public async Task<bool> 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<bool> Response { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
public TaskCompletionSource Removed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
}
|
|
}
|