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
@@ -1,10 +1,90 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary;
using System.Text.Json;
namespace MeetingAssistant.Tests;
public sealed class ProjectKnowledgeToolTests
{
[Theory]
[InlineData("name: Incomplete project")]
[InlineData("description: Missing name")]
[InlineData("name: ' '\ndescription: Missing name")]
public async Task CatalogUsesLegacyFallbackForIncompleteMetadata(string frontmatter)
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectRoot = Path.Combine(root, "Projects", "Alpha");
Directory.CreateDirectory(projectRoot);
await File.WriteAllTextAsync(Path.Combine(projectRoot, "PROJECT.md"), $"---\n{frontmatter}\n---\nNotes");
var tools = new MeetingSummaryTools(CreateArtifacts(root),
new MeetingAssistantOptions { Vault = { ProjectsFolder = Path.Combine(root, "Projects") } });
using var catalog = JsonDocument.Parse(await tools.ListProjects());
var project = Assert.Single(catalog.RootElement.EnumerateArray());
Assert.Equal("Alpha", project.GetProperty("displayname").GetString());
Assert.Equal("", project.GetProperty("description").GetString());
}
[Fact]
public async Task ProjectMetadataWriteEnforcesDescriptionLimitAndPreservesOtherContent()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectRoot = Path.Combine(root, "Projects", "Alpha");
Directory.CreateDirectory(projectRoot);
var artifacts = CreateArtifacts(root);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await File.WriteAllTextAsync(artifacts.MeetingNotePath, "---\nprojects: Alpha\n---");
var tools = new MeetingSummaryTools(artifacts,
new MeetingAssistantOptions { Vault = { ProjectsFolder = Path.Combine(root, "Projects") } });
var original = $"---\nname: Alpha Platform\ndescription: {new string('a', 256)}\nowner: Ada\n---\nUser-authored project notes.";
Assert.Equal("Alpha/PROJECT.md", await tools.WriteProjectFile("Alpha", "PROJECT.md", original));
var rejected = await tools.WriteProjectFile("Alpha", "PROJECT.md",
$"description: {new string('b', 257)}", from: 3, to: 3);
Assert.StartsWith("Refused:", rejected);
Assert.Equal(original, await File.ReadAllTextAsync(Path.Combine(projectRoot, "PROJECT.md")));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("Alpha", "PROJECT.md", "No metadata", replace_file: true));
Assert.Equal("Alpha/PROJECT.md", await tools.WriteProjectFile("Alpha", "PROJECT.md",
"description: Updated project purpose", from: 3, to: 3));
var updated = await tools.ReadProjectFile("Alpha", "PROJECT.md");
Assert.Contains("owner: Ada", updated);
Assert.Contains("User-authored project notes.", updated);
Assert.Contains("Updated project purpose", await tools.ListProjects());
}
[Fact]
public async Task CatalogListsAllProjectsWithMetadataAndLegacyFallbackWithoutGrantingReadAccess()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
Directory.CreateDirectory(Path.Combine(projectsRoot, "Alpha"));
Directory.CreateDirectory(Path.Combine(projectsRoot, "Legacy"));
Directory.CreateDirectory(Path.Combine(projectsRoot, "Malformed"));
await File.WriteAllTextAsync(Path.Combine(projectsRoot, "Alpha", "PROJECT.md"),
"---\nname: Alpha Platform\ndescription: Meeting automation\n---\nPrivate project notes");
await File.WriteAllTextAsync(Path.Combine(projectsRoot, "Alpha", "AGENTS.md"), "Private instructions");
await File.WriteAllTextAsync(Path.Combine(projectsRoot, "Malformed", "PROJECT.md"), "---\nname: [broken\n---");
var tools = new MeetingSummaryTools(CreateArtifacts(root),
new MeetingAssistantOptions { Vault = { ProjectsFolder = projectsRoot } });
var catalog = await tools.ListProjects();
using var json = JsonDocument.Parse(catalog);
var projects = json.RootElement.EnumerateArray().ToArray();
Assert.Equal(3, projects.Length);
Assert.Equal("Alpha", projects[0].GetProperty("id").GetString());
Assert.Equal("Alpha Platform", projects[0].GetProperty("displayname").GetString());
Assert.Equal("Meeting automation", projects[0].GetProperty("description").GetString());
Assert.Equal("Legacy", projects[1].GetProperty("displayname").GetString());
Assert.Equal("", projects[1].GetProperty("description").GetString());
Assert.Equal("Malformed", projects[2].GetProperty("displayname").GetString());
Assert.Equal("", projects[2].GetProperty("description").GetString());
Assert.DoesNotContain("Private", catalog);
Assert.Equal("", await tools.ReadProjectFile("Alpha", "AGENTS.md"));
}
[Fact]
public async Task ToolsOperateOnProjectsBoundInMeetingFrontmatter()
{
@@ -48,7 +128,7 @@ public sealed class ProjectKnowledgeToolTests
}
});
Assert.Equal("MeetingAssistant", await tools.ListProjects());
Assert.Contains("MeetingAssistant", await tools.ListProjects());
Assert.Equal("README.md\nnotes/context.md", await tools.ListProjectFiles("MeetingAssistant"));
Assert.Equal("Second alpha line\nThird beta line", await tools.ReadProjectFile("MeetingAssistant", "README.md", 2, 99));
@@ -56,15 +136,15 @@ public sealed class ProjectKnowledgeToolTests
Assert.Equal("MeetingAssistant/notes/summary.md", writeResult);
Assert.Equal("# Project Update", await File.ReadAllTextAsync(Path.Combine(meetingAssistantRoot, "notes", "summary.md")));
Assert.Equal("IgnoredProject/ignored.md", await tools.WriteProjectFile("IgnoredProject", "ignored.md", "changed"));
Assert.Equal("alpha should not be searched\nchanged", await File.ReadAllTextAsync(Path.Combine(ignoredRoot, "ignored.md")));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("IgnoredProject", "ignored.md", "changed"));
Assert.Equal("alpha should not be searched", await File.ReadAllTextAsync(Path.Combine(ignoredRoot, "ignored.md")));
Assert.Equal(
"README.md:2 Second alpha line",
await tools.Search("alpha"));
}
[Fact]
public async Task ListProjectsAcceptsScalarProjectFrontmatter()
public async Task ProjectFileAccessAcceptsScalarProjectFrontmatter()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
@@ -88,7 +168,8 @@ public sealed class ProjectKnowledgeToolTests
}
});
Assert.Equal("MeetingAssistant", await tools.ListProjects());
Assert.Equal("MeetingAssistant/notes.md", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "Bound project"));
Assert.Equal("Bound project", await tools.ReadProjectFile("MeetingAssistant", "notes.md"));
}
[Fact]
@@ -100,8 +181,11 @@ public sealed class ProjectKnowledgeToolTests
Directory.CreateDirectory(projectRoot);
var projectFile = Path.Combine(projectRoot, "notes.md");
await File.WriteAllTextAsync(projectFile, "one\ntwo\nthree\nfour");
var artifacts = CreateArtifacts(root);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await File.WriteAllTextAsync(artifacts.MeetingNotePath, "---\nprojects: MeetingAssistant\n---\n");
var tools = new MeetingSummaryTools(
CreateArtifacts(root),
artifacts,
new MeetingAssistantOptions
{
Vault =
@@ -132,8 +216,13 @@ public sealed class ProjectKnowledgeToolTests
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
Directory.CreateDirectory(Path.Combine(projectsRoot, "MeetingAssistant"));
var artifacts = CreateArtifacts(root);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await File.WriteAllTextAsync(artifacts.MeetingNotePath, "---\nprojects: MeetingAssistant\n---\n");
var outsidePath = Path.Combine(projectsRoot, "outside.md");
await File.WriteAllTextAsync(outsidePath, "Preserve outside content");
var tools = new MeetingSummaryTools(
CreateArtifacts(root),
artifacts,
new MeetingAssistantOptions
{
Vault =
@@ -144,6 +233,7 @@ public sealed class ProjectKnowledgeToolTests
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MissingProject", "notes.md", "content"));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "../outside.md", "content"));
Assert.Equal("Preserve outside content", await File.ReadAllTextAsync(outsidePath));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1, to: 1, insert: 1));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1, to: 1, replace_file: true));