From a6677daa93e96315381de23a1656daf0ebda8c39 Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Sat, 18 Jul 2026 00:32:37 +0530 Subject: [PATCH] Dedup skill files by exact path, not case-folded path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit discoverResourceFiles and discoverScriptFiles deduplicated discovered files using strings.ToLower(filePath) as the seen-set key while keeping the original-case path as the file Name. On a case-sensitive filesystem (e.g. Linux, the primary CI target) two genuinely distinct files in the same skill directory that differ only in case — Data.json vs data.json, or Run.py vs run.py — collapse to one key, so the second is silently dropped. Dedup by the exact path instead, which still guards against visiting the same file twice but no longer conflates distinct files. Adds a black-box test using a case-sensitive fstest.MapFS. --- agent/skills/fsskills/source.go | 17 +++++++---- agent/skills/fsskills/source_script_test.go | 32 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/agent/skills/fsskills/source.go b/agent/skills/fsskills/source.go index e3f30a12..9d05462e 100644 --- a/agent/skills/fsskills/source.go +++ b/agent/skills/fsskills/source.go @@ -425,11 +425,14 @@ func (s *Source) discoverResourceFiles(skillFS fs.FS, skillName string) []skills seen := make(map[string]bool) var resources []skills.Resource s.scanForFiles(skillFS, ".", skillName, 1, s.allowedResourceExtensions, s.resourceFilter, "resource", func(filePath string) { - key := strings.ToLower(filePath) - if seen[key] { + // Dedup by the exact path (guards against the same file being visited + // twice), not a case-folded key, which would wrongly collapse two + // distinct files (e.g. Data.json and data.json) on a case-sensitive + // filesystem and silently drop one. + if seen[filePath] { return } - seen[key] = true + seen[filePath] = true resources = append(resources, skills.Resource{ Name: filePath, Read: func(context.Context) (any, error) { @@ -448,11 +451,13 @@ func (s *Source) discoverScriptFiles(skillFS fs.FS, skillName string) []skills.S seen := make(map[string]bool) var scripts []skills.Script s.scanForFiles(skillFS, ".", skillName, 1, s.allowedScriptExtensions, s.scriptFilter, "script", func(filePath string) { - key := strings.ToLower(filePath) - if seen[key] { + // Dedup by the exact path (not a case-folded key) so two distinct + // files differing only in case are both kept on a case-sensitive + // filesystem. See discoverResourceFiles for details. + if seen[filePath] { return } - seen[key] = true + seen[filePath] = true scripts = append(scripts, newScript(filePath, skillFS, s.scriptRunner)) }) return scripts diff --git a/agent/skills/fsskills/source_script_test.go b/agent/skills/fsskills/source_script_test.go index 744fd889..c33790f7 100644 --- a/agent/skills/fsskills/source_script_test.go +++ b/agent/skills/fsskills/source_script_test.go @@ -9,6 +9,7 @@ import ( "sort" "strings" "testing" + "testing/fstest" "github.com/microsoft/agent-framework-go/agent/skills" "github.com/microsoft/agent-framework-go/agent/skills/fsskills" @@ -493,3 +494,34 @@ func TestFileSkill_ScriptContent_IncludesDefaultArraySchema(t *testing.T) { t.Fatalf("expected JSON quotes to be preserved in schema content, got: %s", content) } } + +// On a case-sensitive filesystem, two files in the same skill directory that +// differ only in case (e.g. Data.json and data.json) are distinct files and +// must both be discovered. Deduping on a lowercased path collapsed them. +func TestFileSource_CaseSensitiveFS_KeepsDistinctlyCasedFiles(t *testing.T) { + // fstest.MapFS keys are case-sensitive regardless of the host filesystem. + fsys := fstest.MapFS{ + "case-skill/SKILL.md": {Data: []byte("---\nname: case-skill\ndescription: d\n---\nbody")}, + "case-skill/Data.json": {Data: []byte("A")}, + "case-skill/data.json": {Data: []byte("B")}, + "case-skill/Run.py": {Data: []byte("A")}, + "case-skill/run.py": {Data: []byte("B")}, + } + source := fsskills.NewSourceOptions(fsskills.SourceOptions{ + ScriptRunner: func(context.Context, *skills.Skill, *skills.Script, []string) (any, error) { return nil, nil }, + }, fsys) + + loaded, err := source.Skills(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(loaded) != 1 { + t.Fatalf("expected 1 skill, got %d", len(loaded)) + } + if got := len(loaded[0].Resources); got != 2 { + t.Errorf("expected 2 resources (Data.json + data.json) on a case-sensitive FS, got %d", got) + } + if got := len(loaded[0].Scripts); got != 2 { + t.Errorf("expected 2 scripts (Run.py + run.py) on a case-sensitive FS, got %d", got) + } +}