Public Access
Preserve recovery transcripts and add user-approved agent requests
PR and Push Build/Test / build-and-test (push) Successful in 12m0s
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.
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user