From 45f69f3bfd0c89b7cf1b641377f4ce6c0359c1d4 Mon Sep 17 00:00:00 2001
From: WillowDev <216433317+willowdev-code@users.noreply.github.com>
Date: Mon, 26 Oct 2026 14:09:30 +0100
Subject: [PATCH] ADO Lens 0.9.5
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- `ado_file` by name works without Code Search (not installed or failing): the file lists of the TFVC Metadata
folders of the branches (also in another project of the organization) and of the Git repositories are searched, and
the matches come as full paths ($/…/Main/…, $/…/Release/…) to call again with. Nothing found: an example full path.
`ADOLENS_NO_CODE_SEARCH=1` tries this way on purpose.
- `ado_attachments file=all`: every image of the item (pasted into its text or comments, or attached) in one answer;
`file="1;3"` several files by number (e.g. three screenshots all called image.png).
---
CHANGELOG.md | 9 +++
README.md | 2 +-
README.pl.md | 2 +-
src/AdoLens/AdoLens.csproj | 2 +-
src/AdoLens/Attachments.cs | 37 ++++++++++-
src/AdoLens/Knowledge.cs | 91 +++++++++++++++++++++++++-
src/AdoLens/McpTools.cs | 2 +-
tests/AdoLens.Tests/AttachmentTests.cs | 40 +++++++++++
8 files changed, 177 insertions(+), 8 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d23f614..2857ebb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## 0.9.5 — 2026-10-26
+
+- `ado_file` by name works without Code Search (not installed or failing): the file lists of the TFVC Metadata
+ folders of the branches (also in another project of the organization) and of the Git repositories are searched, and
+ the matches come as full paths ($/…/Main/…, $/…/Release/…) to call again with. Nothing found: an example full path.
+ `ADOLENS_NO_CODE_SEARCH=1` tries this way on purpose.
+- `ado_attachments file=all`: every image of the item (pasted into its text or comments, or attached) in one answer;
+ `file="1;3"` several files by number (e.g. three screenshots all called image.png).
+
## 0.9.4 — 2026-10-26
- `ado_trace`: a branch earlier in the environment flow than the one a change was merged into is not reported as
diff --git a/README.md b/README.md
index 39641c3..930911c 100644
--- a/README.md
+++ b/README.md
@@ -225,7 +225,7 @@ What each MCP tool answers, when Claude should reach for it, and the CLI command
| `ado_wiki_write` | `create` a page, `append` / `prepend` to it, or `replace` it with the version from the last full read — a page changed by someone else in the meantime is never overwritten; optional change comment | release notes, keeping docs current | `adolens wiki-write "Team/Release notes" --file notes.md --mode append` |
| `ado_tests` | the tests of work items (ids) or of a sprint's requirements and bugs: their Test Cases with the latest outcome per plan, suite and configuration (who, when), items without tests; `detail=full` adds the steps | "is it tested", sprint review | `adolens tests 123`, `adolens tests` (current sprint) |
| `ado_test_create` | Test Cases for a work item from markdown (`## title`, steps `1. action => expected`), linked as tested-by, in the item's area and iteration; `plan` + `suite` (id or name) add them to a suite | turning acceptance criteria into tests | `adolens test-create 123 --file cases.md --plan R1 --suite Login` |
-| `ado_attachments` | the files of a work item and the screenshots pasted into its text or comments; `file` gives the text of a PDF (`pages=2-5`; scanned pages shown as images), Word, Excel, PowerPoint, e-mail (.msg, .eml), ZIP (list; `part=` one file inside) or text file, or shows an image - the type is read from the bytes, binaries are never sent as text, long documents come as an outline and the start (`detail=full` for all); `save=true` streams the file to disk at any size and returns only its path, size and SHA-256; files over 20 MB or not readable get a link and the hint to save them; `add` attaches a local file | specifications, e-mails and screenshots of items; traces and logs for other tools | `adolens attachments 123 --file 2 --pages 1-3`, `adolens attachments 123 --file 2 --save` |
+| `ado_attachments` | the files of a work item and the screenshots pasted into its text or comments; `file=all` shows every image at once, `file="1;3"` several; `file` gives the text of a PDF (`pages=2-5`; scanned pages shown as images), Word, Excel, PowerPoint, e-mail (.msg, .eml), ZIP (list; `part=` one file inside) or text file, or shows an image - the type is read from the bytes, binaries are never sent as text, long documents come as an outline and the start (`detail=full` for all); `save=true` streams the file to disk at any size and returns only its path, size and SHA-256; files over 20 MB or not readable get a link and the hint to save them; `add` attaches a local file | specifications, e-mails and screenshots of items; traces and logs for other tools | `adolens attachments 123 --file 2 --pages 1-3`, `adolens attachments 123 --file 2 --save` |
| `ado_create_tree` | a tree of work items from an indented list (`- Feature: …`, children indented, `\| description`, `@person`), under an existing item if given; previewed first, one undo removes it all | breaking a feature into requirements and tasks | — |
| `ado_metrics` | done items and estimate per finished sprint (velocity), cycle and lead time (median, 85%), what is in progress longest | "are we getting faster", planning | `adolens metrics --sprints 6` |
| `ado_label` | a D365 message pasted as seen (values in place of %1 %2 … are fine) or `@File:Id`: the label, its text in every language (missing ones flagged) and the objects, X++ methods, fields and controls using it | "where does this error come from" | `adolens label "Item 1234: received 12, on hand 5"` |
diff --git a/README.pl.md b/README.pl.md
index 9938254..1352bc7 100644
--- a/README.pl.md
+++ b/README.pl.md
@@ -226,7 +226,7 @@ Na co odpowiada każde narzędzie MCP, kiedy Claude powinien po nie sięgnąć i
| `ado_wiki_write` | `create` — nowa strona, `append` / `prepend` — dopisanie, `replace` — zastąpienie z wersją z ostatniego pełnego odczytu (strony zmienionej w międzyczasie przez kogoś innego nigdy nie nadpisze); opcjonalny komentarz zmiany | notatki wydania, aktualna dokumentacja | `adolens wiki-write "Team/Release notes" --file notes.md --mode append` |
| `ado_tests` | testy work itemów (id) albo wymagań i błędów sprintu: ich Test Case'y z ostatnim wynikiem w każdym planie, zestawie i konfiguracji (kto, kiedy), elementy bez testów; `detail=full` dodaje kroki | „czy to przetestowane”, przegląd sprintu | `adolens tests 123`, `adolens tests` (bieżący sprint) |
| `ado_test_create` | Test Case'y dla work itemu z markdownu (`## tytuł`, kroki `1. akcja => oczekiwany wynik`), podpięte jako tested-by, w obszarze i iteracji elementu; `plan` + `suite` (id albo nazwa) dodają je do zestawu | kryteria akceptacji zamienione w testy | `adolens test-create 123 --file przypadki.md --plan R1 --suite Logowanie` |
-| `ado_attachments` | pliki work itemu i zrzuty ekranu wklejone w jego treść lub komentarze; `file` daje tekst PDF-a (`pages=2-5`; zeskanowane strony pokazane jako obrazy), Worda, Excela, PowerPointa, maila (.msg, .eml), ZIP-a (lista; `part=` jeden plik ze środka) lub pliku tekstowego, albo pokazuje obraz - typ jest rozpoznawany po bajtach, pliki binarne nigdy nie idą jako tekst, długie dokumenty przychodzą jako spis treści i początek (`detail=full` całość); `save=true` zapisuje plik na dysk strumieniowo w dowolnym rozmiarze i zwraca tylko ścieżkę, rozmiar i SHA-256; pliki ponad 20 MB albo nieczytelne dostają link i podpowiedź zapisu; `add` dołącza plik z dysku | specyfikacje, maile i zrzuty przy elementach; trace'y i logi dla innych narzędzi | `adolens attachments 123 --file 2 --pages 1-3`, `adolens attachments 123 --file 2 --save` |
+| `ado_attachments` | pliki work itemu i zrzuty ekranu wklejone w jego treść lub komentarze; `file=all` pokazuje od razu wszystkie obrazy, `file="1;3"` kilka; `file` daje tekst PDF-a (`pages=2-5`; zeskanowane strony pokazane jako obrazy), Worda, Excela, PowerPointa, maila (.msg, .eml), ZIP-a (lista; `part=` jeden plik ze środka) lub pliku tekstowego, albo pokazuje obraz - typ jest rozpoznawany po bajtach, pliki binarne nigdy nie idą jako tekst, długie dokumenty przychodzą jako spis treści i początek (`detail=full` całość); `save=true` zapisuje plik na dysk strumieniowo w dowolnym rozmiarze i zwraca tylko ścieżkę, rozmiar i SHA-256; pliki ponad 20 MB albo nieczytelne dostają link i podpowiedź zapisu; `add` dołącza plik z dysku | specyfikacje, maile i zrzuty przy elementach; trace'y i logi dla innych narzędzi | `adolens attachments 123 --file 2 --pages 1-3`, `adolens attachments 123 --file 2 --save` |
| `ado_create_tree` | drzewo work itemów z listy z wcięciami (`- Feature: …`, dzieci wcięte, `\| opis`, `@osoba`), opcjonalnie pod istniejącym elementem; najpierw podgląd, jedno cofnięcie usuwa całość | rozbicie feature'a na wymagania i taski | — |
| `ado_metrics` | zrobione elementy i szacunek w zakończonych sprintach (prędkość), czas realizacji (mediana, 85%), co najdłużej w toku | „czy przyspieszamy”, planowanie | `adolens metrics --sprints 6` |
| `ado_label` | komunikat D365 wklejony tak, jak widać na ekranie (z wartościami zamiast %1 %2 …) albo `@Plik:Id`: etykieta, jej tekst w każdym języku (brakujące oznaczone) i obiekty, metody X++, pola i kontrolki, które jej używają | „skąd ten błąd” | `adolens label "Towar 1234: przyjęto 12, stan 5"` |
diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj
index fecfc88..3eb1e49 100644
--- a/src/AdoLens/AdoLens.csproj
+++ b/src/AdoLens/AdoLens.csproj
@@ -23,7 +23,7 @@
ADO Lens for Azure DevOps
Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP
Copyright © 2026 WillowDev
- 0.9.4
+ 0.9.5
diff --git a/src/AdoLens/Attachments.cs b/src/AdoLens/Attachments.cs
index 04ed2cb..4a3cb45 100644
--- a/src/AdoLens/Attachments.cs
+++ b/src/AdoLens/Attachments.cs
@@ -52,6 +52,13 @@ void From(string? html, string where)
return found;
}
+ /// At most this many files in one answer (file=all or a list of numbers).
+ const int MaxMany = 8;
+
+ static bool IsImage(Attached a) =>
+ a.Comment?.StartsWith("pasted", StringComparison.Ordinal) == true
+ || Path.GetExtension(a.Name).ToLowerInvariant() is ".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".webp";
+
static string Size(long? bytes) => bytes is not { } b ? "" : b < 1024 ? $" ({b} B)" : b < 1024 * 1024 ? $" ({b / 1024} KB)" : $" ({b / 1024.0 / 1024:0.#} MB)";
///
@@ -71,7 +78,35 @@ void From(string? html, string where)
if (list.Count == 0) return ($"{head}: no attachments and no pasted images", []);
if (string.IsNullOrWhiteSpace(file))
return ($"{head}: {list.Count} file(s)\n" + string.Join('\n', list.Select((a, i) => $"{i + 1}. {a.Name}{Size(a.Size)}{(a.Comment is { Length: > 0 } c ? $" - {c}" : "")}")) +
- "\nfile= reads one (documents as text, images and scans shown); save=true saves it to disk (any size).", []);
+ "\nfile= reads one (documents as text, images and scans shown), file=all shows every image, file=\"1;3\" several; save=true saves it to disk (any size).", []);
+
+ // Several at once: file=all (every image, pasted or attached) or numbers "1;3;4".
+ var many = file.Trim().ToLowerInvariant() is "all" or "images" ? list.Where(IsImage).ToList()
+ : file.Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) is { Length: > 1 } numbers
+ ? numbers.Select(x => int.TryParse(x, out var k) && k >= 1 && k <= list.Count ? list[k - 1] : null).OfType().ToList()
+ : null;
+ if (many is not null)
+ {
+ if (many.Count == 0) return ($"{head}: no images among its {list.Count} file(s)", []);
+ var shown = new StringBuilder($"{head}: {Math.Min(many.Count, MaxMany)} of {many.Count} file(s)");
+ var images = new List<(byte[] Bytes, string Mime)>();
+ foreach (var a in many.Take(MaxMany))
+ {
+ var url = a.Url + (a.Url.Contains('?') ? "&" : "?") + "download=true";
+ try
+ {
+ var data = await session.Client.GetBytesAsync(url, MaxDownload, ct);
+ var read = Documents.Read(a.Name, data, pages, part);
+ images.AddRange(read.Images);
+ shown.Append($"\n- {a.Name} ({Documents.Size(data.Length)}{(a.Comment is { Length: > 0 } c ? $", {c}" : "")})")
+ .Append(read.Images.Count > 0 ? $": {read.Images.Count} image(s)" : "")
+ .Append(read.Text is { Length: > 0 } t ? "\n" + Documents.Fit(t, MaxText / MaxMany, false) : "");
+ }
+ catch (AdoApiException ex) { shown.Append($"\n- {a.Name}: not read ({Formatting.Collapse(ex.Message, 80)})"); }
+ }
+ if (many.Count > MaxMany) shown.Append($"\n… {many.Count - MaxMany} more: file= for the rest");
+ return (shown.ToString(), images);
+ }
var same = list.Select((a, i) => (a, i)).Where(x => x.a.Name.Equals(file.Trim(), StringComparison.OrdinalIgnoreCase)).ToList();
if (same.Count > 1)
diff --git a/src/AdoLens/Knowledge.cs b/src/AdoLens/Knowledge.cs
index baa2338..4710478 100644
--- a/src/AdoLens/Knowledge.cs
+++ b/src/AdoLens/Knowledge.cs
@@ -143,6 +143,78 @@ static async Task CodeAsync(AdoClient client, string project, string tex
sealed record FileRef(bool Tfvc, string Path, string? RepoId, string? RepoName, string Project);
+ /// File lists already read in this process: TFVC folder or Git repository → its file paths.
+ static readonly System.Collections.Concurrent.ConcurrentDictionary> Listings = new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Files whose path ends with (a name with or without ".xml"), from the file lists of the
+ /// TFVC Metadata folders (found under $/project, a few levels deep) and of up to five Git repositories; and an
+ /// example full path to suggest when nothing matches.
+ ///
+ static async Task<(List Hits, string? Hint)> ListingAsync(Session session, string project, string path, string? repo, CancellationToken ct)
+ {
+ var client = session.Client;
+ var tail = "/" + path.TrimStart('/');
+ bool Match(string p) => p.EndsWith(tail, StringComparison.OrdinalIgnoreCase) || p.EndsWith(tail + ".xml", StringComparison.OrdinalIgnoreCase);
+ var hits = new List();
+ string? hint = null;
+
+ // TFVC: the Metadata folders of the branches ($/Project/Trunk/Main/Metadata, $/Project/Release/Metadata, ...).
+ var metadata = new List();
+ // The project's own TFVC first; code kept in another project of the organization is found from $/.
+ foreach (var start in new[] { $"$/{project}", "$/" })
+ {
+ if (metadata.Count > 0) break;
+ try
+ {
+ var level = new List { start };
+ for (var depth = 0; depth < (start == "$/" ? 5 : 4) && level.Count > 0 && metadata.Count < 6; depth++)
+ {
+ var next = new List();
+ foreach (var folder in level.Take(15))
+ {
+ var children = (await client.GetListAsync($"_apis/tfvc/items?scopePath={Uri.EscapeDataString(folder)}&recursionLevel=OneLevel", ct))
+ .Where(i => Json.Bool(i, "isFolder")).Select(i => Json.Str(i, "path") ?? "").Where(p => p.Length > 0 && !p.Equals(folder, StringComparison.OrdinalIgnoreCase)).ToList();
+ foreach (var c in children)
+ if (c.EndsWith("/Metadata", StringComparison.OrdinalIgnoreCase)) metadata.Add(c);
+ else next.Add(c);
+ }
+ level = next;
+ }
+ }
+ catch (AdoApiException) { }
+ }
+ foreach (var root in metadata.Take(6))
+ {
+ try
+ {
+ if (!Listings.TryGetValue(root, out var files))
+ Listings[root] = files = (await client.GetListAsync($"_apis/tfvc/items?scopePath={Uri.EscapeDataString(root)}&recursionLevel=Full", ct))
+ .Where(i => !Json.Bool(i, "isFolder")).Select(i => Json.Str(i, "path") ?? "").ToList();
+ hits.AddRange(files.Where(Match).Select(p => new CodeHit("", p, null, 1, true)));
+ }
+ catch (AdoApiException) { }
+ }
+ if (metadata.Count > 0) hint = $"{metadata[0]}///AxClass/{path.Trim('/')}{(path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ? "" : ".xml")}";
+
+ // Git: the repositories of the project (or the one given).
+ try
+ {
+ var repos = repo is not null ? [Json.Str(await client.GetAsync($"{AdoClient.Scope(project)}_apis/git/repositories/{Uri.EscapeDataString(repo)}", ct), "name") ?? repo]
+ : (await client.GetListAsync($"{AdoClient.Scope(project)}_apis/git/repositories", ct)).Select(r => Json.Str(r, "name") ?? "").Where(n => n.Length > 0).Take(5).ToList();
+ foreach (var r in repos)
+ {
+ if (!Listings.TryGetValue($"git:{project}/{r}", out var files))
+ Listings[$"git:{project}/{r}"] = files = (await client.GetListAsync($"{AdoClient.Scope(project)}_apis/git/repositories/{Uri.EscapeDataString(r)}/items?scopePath=/&recursionLevel=Full", ct))
+ .Where(i => !Json.Bool(i, "isFolder")).Select(i => Json.Str(i, "path") ?? "").ToList();
+ hits.AddRange(files.Where(Match).Select(p => new CodeHit(r, p, null, 1, false)));
+ hint ??= $"repo={r} path=//{path.Trim('/')}";
+ }
+ }
+ catch (AdoApiException) { }
+ return (hits.Take(20).ToList(), hint);
+ }
+
///
/// A TFVC path ($/…), a Git path with its repository, or only a file name: then Code Search finds it (several
/// matches are listed for the caller to choose).
@@ -176,12 +248,25 @@ sealed record FileRef(bool Tfvc, string Path, string? RepoId, string? RepoName,
}
var name = path[(path.LastIndexOf('/') + 1)..];
List hits;
- try { (hits, _) = await CodeHitsAsync(session.Client, project, $"file:{name}", 20, repo, ct); }
+ try
+ {
+ // ADOLENS_NO_CODE_SEARCH=1 tries the way without Code Search (diagnostics).
+ if (Environment.GetEnvironmentVariable("ADOLENS_NO_CODE_SEARCH") == "1") throw new AdoApiException("Code Search turned off by ADOLENS_NO_CODE_SEARCH");
+ (hits, _) = await CodeHitsAsync(session.Client, project, $"file:{name}", 20, repo, ct);
+ }
catch (AdoApiException ex)
{
- throw new InvalidOperationException($"give the full path ($/… for TFVC, or the path with repo= for Git): finding files by name needs Code Search ({ex.Message})");
+ // Without Code Search (not installed, or failing): the file lists of the TFVC Metadata folders and of the
+ // Git repositories, read once per session.
+ var (listed, hint) = await ListingAsync(session, project, path, repo, ct);
+ if (listed.Count == 0)
+ throw new InvalidOperationException($"Code Search failed ({Formatting.Collapse(ex.Message, 120)}) and no file '{path}' is in the file lists either; " +
+ $"give the full path{(hint is null ? " ($/… for TFVC, or the path with repo= for Git)" : $", e.g. {hint}")}");
+ hits = listed;
}
- var matching = hits.Where(h => h.Path.EndsWith(path.StartsWith('/') ? path : "/" + path, StringComparison.OrdinalIgnoreCase)).ToList();
+ var wanted = path.StartsWith('/') ? path : "/" + path;
+ // An X++ object may be named without ".xml".
+ var matching = hits.Where(h => h.Path.EndsWith(wanted, StringComparison.OrdinalIgnoreCase) || h.Path.EndsWith(wanted + ".xml", StringComparison.OrdinalIgnoreCase)).ToList();
if (matching.Count == 1)
{
var h = matching[0];
diff --git a/src/AdoLens/McpTools.cs b/src/AdoLens/McpTools.cs
index 775303d..f94d7b4 100644
--- a/src/AdoLens/McpTools.cs
+++ b/src/AdoLens/McpTools.cs
@@ -583,7 +583,7 @@ public static Task CreateTree(Lens lens, string items,
() => WorkTree.CreateAsync(lens, connection, project, parent, items, iteration, area, confirm, ct));
[McpServerTool(Name = "ado_attachments"), Toolset(Toolsets.Code)]
- [Description("Files of a work item and screenshots pasted into it: the list; file= gives a document's text (PDF, Word, Excel, PowerPoint, e-mail, ZIP list, text) or shows images and scanned pages; save=true streams it to disk (any size) and returns the path; add= attaches a file.")]
+ [Description("Files of a work item and screenshots pasted into it: the list; file= gives a document's text (PDF, Word, Excel, PowerPoint, e-mail, ZIP list, text) or shows images and scanned pages, file=all every image at once, file=\"1;3\" several; save=true streams it to disk (any size) and returns the path; add= attaches a file.")]
public static async Task> AttachmentsTool(Lens lens, int id,
string? file = null,
[Description("PDF pages: 3 or 2-5.")] string? pages = null,
diff --git a/tests/AdoLens.Tests/AttachmentTests.cs b/tests/AdoLens.Tests/AttachmentTests.cs
index 3b6091c..c66025c 100644
--- a/tests/AdoLens.Tests/AttachmentTests.cs
+++ b/tests/AdoLens.Tests/AttachmentTests.cs
@@ -17,6 +17,46 @@ static byte[] Zip(params (string Path, string Content)[] parts)
return ms.ToArray();
}
+ [Fact]
+ public async Task All_images_of_an_item_come_in_one_answer()
+ {
+ var items = new DemoItems();
+ var rel = (string id) => new System.Text.Json.Nodes.JsonObject
+ {
+ ["rel"] = "AttachedFile",
+ ["url"] = $"https://dev.azure.com/contoso/_apis/wit/attachments/{id}?fileName=image.png",
+ ["attributes"] = new System.Text.Json.Nodes.JsonObject { ["name"] = "image.png", ["resourceSize"] = 9 },
+ };
+ items.Items[1]["relations"] = new System.Text.Json.Nodes.JsonArray(rel("a1"), rel("a2"), new System.Text.Json.Nodes.JsonObject
+ {
+ ["rel"] = "AttachedFile", ["url"] = "https://dev.azure.com/contoso/_apis/wit/attachments/a3?fileName=spec.txt",
+ ["attributes"] = new System.Text.Json.Nodes.JsonObject { ["name"] = "spec.txt", ["resourceSize"] = 4 },
+ });
+ byte[] png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0];
+ var ado = items.Routes(new DemoProject().Routes(new FakeAdo()))
+ .On("/contoso/_apis/wit/attachments/", _ => new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new ByteArrayContent(png) });
+ var dir = Path.Combine(Path.GetTempPath(), $"adolens-att-{Guid.NewGuid():N}");
+ try
+ {
+ var s = new Settings { DefaultConnection = "contoso" };
+ s.Connections["contoso"] = new Connection { Organization = "contoso", Project = "Demo" };
+ var lens = new Lens(s, "0.0.0", dir, _ => null, _ => "pat", _ => ado.Client());
+
+ var (text, images) = await Attachments.ReadAsync(lens, null, 1, "all", null);
+ Assert.Contains("2 of 2 file(s)", text);
+ Assert.Equal(2, images.Count);
+
+ // Two files with one name: a list of numbers takes both.
+ var (_, picked) = await Attachments.ReadAsync(lens, null, 1, "1;2", null);
+ Assert.Equal(2, picked.Count);
+ }
+ finally
+ {
+ Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
+ if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
+ }
+ }
+
[Fact]
public void Word_documents_become_markdown_like_text()
{