Public Access
Fix past meeting summary tool edge cases
PR and Push Build/Test / build-and-test (push) Successful in 10m54s
PR and Push Build/Test / build-and-test (push) Successful in 10m54s
This commit is contained in:
@@ -56,6 +56,21 @@ public sealed class PastProjectMeetingSummaryToolTests
|
||||
Assert.Equal("Refused: project(s) not assigned to current meeting: Project Z.", refused);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPastProjectMeetingsRejectsOutOfScopeProjectsWhenMeetingHasNoProjects()
|
||||
{
|
||||
var root = CreateRoot();
|
||||
var artifacts = CreateArtifacts(root);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(Path.Combine(root, "Meetings", "Summaries"));
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath);
|
||||
var tools = CreateTools(artifacts, root);
|
||||
|
||||
var refused = await tools.ListPastProjectMeetings(["Project Z"]);
|
||||
|
||||
Assert.Equal("Refused: project(s) not assigned to current meeting: Project Z.", refused);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadPastProjectMeetingSummaryReadsOnlyScopedPastSummaryFiles()
|
||||
{
|
||||
@@ -83,6 +98,76 @@ public sealed class PastProjectMeetingSummaryToolTests
|
||||
Assert.StartsWith("Refused:", await tools.ReadPastProjectMeetingSummary(outside));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadPastProjectMeetingSummaryAcceptsSearchResultPastMeetingPrefix()
|
||||
{
|
||||
var root = CreateRoot();
|
||||
var artifacts = CreateArtifacts(root);
|
||||
var summariesRoot = Path.Combine(root, "Meetings", "Summaries");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(summariesRoot);
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X");
|
||||
await WriteSummaryAsync(
|
||||
Path.Combine(summariesRoot, "project-x.md"),
|
||||
"Project X",
|
||||
"2026-05-28T09:00:00+02:00",
|
||||
"Project X",
|
||||
"searchable past meeting");
|
||||
var tools = CreateTools(artifacts, root);
|
||||
|
||||
var search = await tools.Search("searchable", ["Project X"]);
|
||||
var content = await tools.ReadPastProjectMeetingSummary("past-meetings/project-x.md");
|
||||
|
||||
Assert.Contains("past-meetings/project-x.md:", search);
|
||||
Assert.Contains("searchable past meeting", search);
|
||||
Assert.Contains("searchable past meeting", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPastProjectMeetingsIgnoresMalformedSummaryFrontmatter()
|
||||
{
|
||||
var root = CreateRoot();
|
||||
var artifacts = CreateArtifacts(root);
|
||||
var summariesRoot = Path.Combine(root, "Meetings", "Summaries");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(summariesRoot);
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X");
|
||||
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-x.md"), "Project X", "2026-05-28T09:00:00+02:00", "Project X");
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(summariesRoot, "broken.md"),
|
||||
"""
|
||||
---
|
||||
projects: [not valid
|
||||
---
|
||||
|
||||
broken
|
||||
""");
|
||||
var tools = CreateTools(artifacts, root);
|
||||
|
||||
var result = await tools.ListPastProjectMeetings();
|
||||
|
||||
Assert.Contains("project-x.md", result);
|
||||
Assert.DoesNotContain("broken.md", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPastProjectMeetingsClampsRequestedPageToLastPage()
|
||||
{
|
||||
var root = CreateRoot();
|
||||
var artifacts = CreateArtifacts(root);
|
||||
var summariesRoot = Path.Combine(root, "Meetings", "Summaries");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(summariesRoot);
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X");
|
||||
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-x.md"), "Project X", "2026-05-28T09:00:00+02:00", "Project X");
|
||||
var tools = CreateTools(artifacts, root);
|
||||
|
||||
var result = await tools.ListPastProjectMeetings(page: 99, page_size: 1);
|
||||
|
||||
Assert.Contains("page 1/1", result);
|
||||
Assert.Contains("project-x.md", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchIncludesProjectFilesAndScopedPastMeetingSummaries()
|
||||
{
|
||||
@@ -111,6 +196,20 @@ public sealed class PastProjectMeetingSummaryToolTests
|
||||
Assert.Equal("Refused: project(s) not assigned to current meeting: Project Z.", refused);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchReportsInvalidPatternInsteadOfNoMatches()
|
||||
{
|
||||
var root = CreateRoot();
|
||||
var artifacts = CreateArtifacts(root);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X");
|
||||
var tools = CreateTools(artifacts, root);
|
||||
|
||||
var result = await tools.Search("[", ["Project X"]);
|
||||
|
||||
Assert.StartsWith("Search failed: invalid .NET regular expression:", result);
|
||||
}
|
||||
|
||||
private static string CreateRoot()
|
||||
{
|
||||
return Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
|
||||
@@ -29,7 +29,7 @@ public sealed class BoundMeetingProjectResolver
|
||||
return [];
|
||||
}
|
||||
|
||||
var projectNames = await ReadMeetingProjectNamesAsync(artifacts.MeetingNotePath, cancellationToken);
|
||||
var projectNames = await ReadMeetingProjectNamesAsync(artifacts, cancellationToken);
|
||||
if (projectNames.Count == 0)
|
||||
{
|
||||
return [];
|
||||
@@ -42,6 +42,13 @@ public sealed class BoundMeetingProjectResolver
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public Task<HashSet<string>> ReadMeetingProjectNamesAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ReadMeetingProjectNamesAsync(artifacts.MeetingNotePath, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<HashSet<string>> ReadMeetingProjectNamesAsync(
|
||||
string meetingNotePath,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -322,15 +322,16 @@ public sealed class MeetingSummaryTools
|
||||
|
||||
var summaries = await GetScopedPastSummariesAsync(scope.ProjectNames);
|
||||
var effectivePageSize = Math.Clamp(page_size, 1, 100);
|
||||
var effectivePage = Math.Max(1, page);
|
||||
var requestedPage = Math.Max(1, page);
|
||||
var totalPages = Math.Max(1, (int)Math.Ceiling(summaries.Count / (double)effectivePageSize));
|
||||
var effectivePage = Math.Min(requestedPage, totalPages);
|
||||
var pageItems = summaries
|
||||
.Skip((effectivePage - 1) * effectivePageSize)
|
||||
.Take(effectivePageSize)
|
||||
.ToList();
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"page {Math.Min(effectivePage, totalPages)}/{totalPages}, page_size {effectivePageSize}, total {summaries.Count}"
|
||||
$"page {effectivePage}/{totalPages}, page_size {effectivePageSize}, total {summaries.Count}"
|
||||
};
|
||||
lines.AddRange(pageItems.Select(summary =>
|
||||
$"{summary.ToolPath} | {FormatSummaryStartTime(summary.StartTime)} | {summary.Title} | projects: {string.Join(", ", summary.Projects)}"));
|
||||
@@ -499,22 +500,17 @@ public sealed class MeetingSummaryTools
|
||||
|
||||
private async Task<ProjectScope> ResolveCurrentMeetingProjectScopeAsync(string[]? projects)
|
||||
{
|
||||
var currentProjects = await ReadCurrentMeetingProjectNamesAsync();
|
||||
if (currentProjects.Count == 0)
|
||||
{
|
||||
return new ProjectScope(new HashSet<string>(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (projects is null || projects.Length == 0)
|
||||
{
|
||||
return new ProjectScope(currentProjects.ToHashSet(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var requested = projects
|
||||
var requested = (projects ?? [])
|
||||
.Where(project => !string.IsNullOrWhiteSpace(project))
|
||||
.Select(project => project.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var currentProjects = await ReadCurrentMeetingProjectNamesAsync();
|
||||
if (requested.Count == 0)
|
||||
{
|
||||
return new ProjectScope(currentProjects.ToHashSet(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var outOfScope = requested
|
||||
.Where(project => !currentProjects.Contains(project))
|
||||
.Order(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -531,11 +527,7 @@ public sealed class MeetingSummaryTools
|
||||
|
||||
private async Task<HashSet<string>> ReadCurrentMeetingProjectNamesAsync()
|
||||
{
|
||||
var note = await ReadMeetingNoteAsync();
|
||||
return note.Frontmatter.Projects
|
||||
.Where(project => !string.IsNullOrWhiteSpace(project))
|
||||
.Select(project => project.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return await projectResolver.ReadMeetingProjectNamesAsync(artifacts);
|
||||
}
|
||||
|
||||
private async Task<List<BoundMeetingProject>> GetExistingProjectsInScopeAsync(IReadOnlySet<string> projectNames)
|
||||
@@ -767,23 +759,25 @@ public sealed class MeetingSummaryTools
|
||||
string keywords,
|
||||
bool singleProject)
|
||||
{
|
||||
System.Text.RegularExpressions.Regex regex;
|
||||
try
|
||||
{
|
||||
regex = new System.Text.RegularExpressions.Regex(keywords);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return $"Search failed: invalid .NET regular expression: {ex.Message}";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var regex = new System.Text.RegularExpressions.Regex(keywords);
|
||||
var matches = new List<string>();
|
||||
foreach (var project in projects)
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(project.Path, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var lines = File.ReadAllLines(file);
|
||||
for (var index = 0; index < lines.Length; index++)
|
||||
{
|
||||
if (regex.IsMatch(lines[index]))
|
||||
{
|
||||
var relative = Path.Combine(project.Name, Path.GetRelativePath(project.Path, file));
|
||||
matches.Add($"{FormatSearchPath(relative, singleProject)}:{index + 1} {lines[index]}");
|
||||
}
|
||||
}
|
||||
var relative = Path.Combine(project.Name, Path.GetRelativePath(project.Path, file));
|
||||
AddFileMatches(matches, regex, file, FormatSearchPath(relative, singleProject));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,9 +788,9 @@ public sealed class MeetingSummaryTools
|
||||
|
||||
return string.Join('\n', matches);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return "";
|
||||
return $"Search failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -877,6 +871,7 @@ public sealed class MeetingSummaryTools
|
||||
|
||||
private async Task<PastMeetingSummaryResolution> ResolvePastSummaryAsync(string path)
|
||||
{
|
||||
path = NormalizePastMeetingSummaryToolPath(path);
|
||||
if (string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path))
|
||||
{
|
||||
return PastMeetingSummaryResolution.Refused("Refused: summary path must be relative to the configured summaries folder.");
|
||||
@@ -914,6 +909,15 @@ public sealed class MeetingSummaryTools
|
||||
return new PastMeetingSummaryResolution(summary);
|
||||
}
|
||||
|
||||
private static string NormalizePastMeetingSummaryToolPath(string path)
|
||||
{
|
||||
var toolPath = ToToolPath(path.Trim());
|
||||
const string pastMeetingPrefix = "past-meetings/";
|
||||
return toolPath.StartsWith(pastMeetingPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
? toolPath[pastMeetingPrefix.Length..]
|
||||
: path;
|
||||
}
|
||||
|
||||
private async Task<PastMeetingSummary?> TryReadPastSummaryAsync(string path, string summariesRoot)
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path);
|
||||
@@ -923,8 +927,17 @@ public sealed class MeetingSummaryTools
|
||||
return null;
|
||||
}
|
||||
|
||||
var frontmatter = YamlDeserializer.Deserialize<SummaryFrontmatterYaml>(document.Frontmatter)
|
||||
?? new SummaryFrontmatterYaml();
|
||||
SummaryFrontmatterYaml frontmatter;
|
||||
try
|
||||
{
|
||||
frontmatter = YamlDeserializer.Deserialize<SummaryFrontmatterYaml>(document.Frontmatter)
|
||||
?? new SummaryFrontmatterYaml();
|
||||
}
|
||||
catch (YamlDotNet.Core.YamlException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var projects = (frontmatter.Projects ?? [])
|
||||
.Where(project => !string.IsNullOrWhiteSpace(project))
|
||||
.Select(project => project.Trim())
|
||||
|
||||
@@ -310,7 +310,7 @@ When assistant context contains cropped screenshot links produced by OCR, the su
|
||||
|
||||
`list_past_project_meetings` reads only summary notes from the configured summaries folder and lists prior summaries assigned to the current meeting's projects, excluding the current summary file. It supports pagination through `page` and `page_size` and can optionally filter to a subset of the current meeting's projects. If the requested project is not assigned to the current meeting, the tool refuses the request. `read_past_project_meeting_summary` reads one of those prior summary files as read-only historical context, using the same `from` and `to` line range behavior as other read tools. It cannot write or mutate past summaries; `write_summary` is only for the current meeting's summary.
|
||||
|
||||
`search` accepts .NET/ripgrep-style regular expression syntax and returns matches as `filename:line text`. It searches both project files and past meeting summaries for the requested current-meeting project scope. If one project is searched, project-file paths are relative to that project; if multiple projects are searched, project-file paths include the project folder name. Past summary matches are prefixed with `past-meetings/`.
|
||||
`search` accepts .NET regular expression syntax and returns matches as `filename:line text`. It searches both project files and past meeting summaries for the requested current-meeting project scope. If one project is searched, project-file paths are relative to that project; if multiple projects are searched, project-file paths include the project folder name. Past summary matches are prefixed with `past-meetings/`, and `read_past_project_meeting_summary` accepts those prefixed paths directly.
|
||||
|
||||
`write_projectfile` writes inside an existing project folder. With no line arguments it appends or creates the file. With `replace_file: true` it replaces the whole file. With `from` and `to` it replaces that inclusive 1-based line range. With `insert` it inserts content at that 1-based line position.
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ The `search` tool SHALL refuse requested project scopes that are not assigned to
|
||||
- **THEN** `read_projectfile` clamps the requested range to the available file lines without failing
|
||||
|
||||
#### Scenario: Agent searches project knowledge
|
||||
- **WHEN** the agent calls `search` with ripgrep syntax keywords
|
||||
- **WHEN** the agent calls `search` with .NET regular expression keywords
|
||||
- **THEN** Meeting Assistant runs the search against the requested projects or the meeting-bound projects and returns matches as `filename:line text`
|
||||
|
||||
#### Scenario: Agent searches past project meeting summaries
|
||||
|
||||
@@ -52,12 +52,16 @@ The summary agent SHALL be able to list paginated past meeting summary notes for
|
||||
|
||||
The past meeting summary listing SHALL read summary notes from the configured summaries folder only.
|
||||
|
||||
The past meeting summary listing SHALL skip malformed or non-summary markdown files instead of failing the whole listing.
|
||||
|
||||
When no project filter is supplied to the past meeting summary listing, Meeting Assistant SHALL include past summaries assigned to any project assigned to the current meeting.
|
||||
|
||||
When a project filter is supplied to the past meeting summary listing, Meeting Assistant SHALL include only past summaries assigned to those requested projects and SHALL refuse requested projects not assigned to the current meeting.
|
||||
|
||||
The summary agent SHALL be able to read past project meeting summary files through a read-only tool. Meeting Assistant SHALL refuse reads for summary files outside the configured summaries folder, the current summary file, or summary files that do not belong to one of the current meeting's assigned projects.
|
||||
|
||||
When search results identify past meeting summaries with the `past-meetings/` prefix, the past meeting summary read tool SHALL accept that prefixed path.
|
||||
|
||||
The summary instructions SHALL clearly distinguish `write_summary`, which writes the current meeting's summary, from past meeting summary read tools, which are read-only historical context.
|
||||
|
||||
The summary pipeline SHALL write the summary note as a markdown artifact linked to the meeting note, transcript, and assistant context.
|
||||
@@ -223,6 +227,14 @@ When `delete_identity` is called with an existing transcript speaker identity, M
|
||||
- **WHEN** the summary agent lists past project meetings for `Project Z`
|
||||
- **THEN** Meeting Assistant refuses the request because `Project Z` is not assigned to the current meeting
|
||||
|
||||
#### Scenario: Past project meeting listing skips malformed notes
|
||||
- **GIVEN** the current meeting note is assigned to `Project X`
|
||||
- **AND** the summaries folder contains one valid prior `Project X` summary note
|
||||
- **AND** the summaries folder contains one markdown file with malformed frontmatter
|
||||
- **WHEN** the summary agent lists past project meetings
|
||||
- **THEN** Meeting Assistant returns the valid prior summary
|
||||
- **AND** does not fail because of the malformed file
|
||||
|
||||
#### Scenario: Past project meeting summary is read-only historical context
|
||||
- **GIVEN** the current meeting note is assigned to `Project X`
|
||||
- **AND** a prior summary note in the configured summaries folder is assigned to `Project X`
|
||||
@@ -230,6 +242,13 @@ When `delete_identity` is called with an existing transcript speaker identity, M
|
||||
- **THEN** Meeting Assistant returns the clamped inclusive line range
|
||||
- **AND** exposes no tool to write that prior summary note
|
||||
|
||||
#### Scenario: Past project meeting search paths can be read
|
||||
- **GIVEN** the current meeting note is assigned to `Project X`
|
||||
- **AND** a prior summary note in the configured summaries folder is assigned to `Project X`
|
||||
- **WHEN** the summary agent searches and receives a `past-meetings/` prefixed summary path
|
||||
- **THEN** the summary agent can pass that prefixed path to `read_past_project_meeting_summary`
|
||||
- **AND** Meeting Assistant reads the prior summary note
|
||||
|
||||
### Requirement: Meeting screenshots are captured into assistant context
|
||||
Meeting Assistant SHALL expose a configurable screenshot hotkey.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user