diff --git a/agent/skills/fsskills/source.go b/agent/skills/fsskills/source.go index b1ef5b38..078b3006 100644 --- a/agent/skills/fsskills/source.go +++ b/agent/skills/fsskills/source.go @@ -436,11 +436,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) { @@ -459,11 +462,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) + } +}