Preserve recovery transcripts and add user-approved agent requests
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:
2026-09-25 11:58:34 +02:00
parent e011e36ef7
commit 7b2bcd3631
50 changed files with 2188 additions and 113 deletions
@@ -0,0 +1,56 @@
namespace MeetingAssistant.Notifications;
public sealed record AgentApprovalPrompt(string Title, string Message, TimeSpan Timeout);
public interface IAgentApprovalPromptService
{
Task<bool> ConfirmAsync(AgentApprovalPrompt prompt, CancellationToken cancellationToken);
}
public sealed class NoopAgentApprovalPromptService : IAgentApprovalPromptService
{
public Task<bool> ConfirmAsync(AgentApprovalPrompt prompt, CancellationToken cancellationToken) => Task.FromResult(false);
}
public sealed class AgentApprovalService
{
private readonly IAgentApprovalPromptService prompts;
private readonly ILogger<AgentApprovalService> logger;
private readonly TimeSpan timeout;
public AgentApprovalService(IAgentApprovalPromptService prompts, ILogger<AgentApprovalService> logger)
: this(prompts, logger, TimeSpan.FromMinutes(10)) { }
internal AgentApprovalService(IAgentApprovalPromptService prompts, ILogger<AgentApprovalService> logger, TimeSpan timeout)
{
this.prompts = prompts;
this.logger = logger;
this.timeout = timeout;
}
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken cancellationToken)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(timeout);
try
{
var accepted = await prompts.ConfirmAsync(new AgentApprovalPrompt(title, message, timeout), deadline.Token)
.WaitAsync(deadline.Token);
cancellationToken.ThrowIfCancellationRequested();
return accepted && !deadline.IsCancellationRequested;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return false;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogWarning(exception, "Could not obtain user approval for {Title}", title);
return false;
}
finally
{
await deadline.CancelAsync();
}
}
}
@@ -0,0 +1,85 @@
#if WINDOWS
using System.Collections.Concurrent;
using CommunityToolkit.WinUI.Notifications;
using MeetingAssistant.Recording;
namespace MeetingAssistant.Notifications;
public sealed class WindowsAgentApprovalPromptService(ILogger<WindowsAgentApprovalPromptService> logger)
: IAgentApprovalPromptService, IDisposable
{
private const string Source = "summary-agent-approval";
private readonly ConcurrentDictionary<string, TaskCompletionSource<bool>> pending = new();
private readonly object gate = new();
private bool registered;
private bool disposed;
public async Task<bool> ConfirmAsync(AgentApprovalPrompt prompt, CancellationToken cancellationToken)
{
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763)) return false;
cancellationToken.ThrowIfCancellationRequested();
var id = Guid.NewGuid().ToString("N");
var response = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
try
{
lock (gate)
{
if (disposed) return false;
if (!registered)
{
ToastNotificationManagerCompat.OnActivated += OnActivated;
registered = true;
}
pending[id] = response;
new ToastContentBuilder()
.AddArgument("source", Source).AddArgument("requestId", id)
.SetToastScenario(ToastScenario.Reminder)
.SetToastDuration(ToastDuration.Long)
.AddText(prompt.Title)
.AddText(prompt.Message)
.AddButton(new ToastButton().SetContent("Yes")
.AddArgument("source", Source).AddArgument("requestId", id)
.AddArgument("response", "yes").SetBackgroundActivation())
.AddButton(new ToastButton().SetContent("No")
.AddArgument("source", Source).AddArgument("requestId", id)
.AddArgument("response", "no").SetBackgroundActivation())
.Show(toast =>
{
toast.Tag = id;
toast.Group = Source;
toast.ExpirationTime = DateTimeOffset.Now.Add(prompt.Timeout);
});
}
return await response.Task.WaitAsync(cancellationToken);
}
finally
{
pending.TryRemove(id, out _);
try { ToastNotificationManagerCompat.History.Remove(id, Source); }
catch (Exception exception) { logger.LogDebug(exception, "Could not remove approval notification {RequestId}", id); }
}
}
private void OnActivated(ToastNotificationActivatedEventArgsCompat args)
{
var arguments = NotificationActivationArguments.Parse(args.Argument);
if (arguments.GetValueOrDefault("source") != Source ||
!arguments.TryGetValue("requestId", out var id) ||
!pending.TryRemove(id, out var response)) return;
response.TrySetResult(arguments.GetValueOrDefault("response") == "yes");
}
public void Dispose()
{
lock (gate)
{
disposed = true;
if (registered) ToastNotificationManagerCompat.OnActivated -= OnActivated;
registered = false;
foreach (var response in pending.Values) response.TrySetResult(false);
pending.Clear();
}
}
}
#endif