diff --git a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts index e7cbc7b5fb4eb..fb4c24e24440f 100644 --- a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts +++ b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts @@ -1010,6 +1010,13 @@ export class SessionCustomizationDiscovery extends Disposable { if (skillStat.isFile && !seen.has(skillFile)) { seen.add(skillFile); files.push({ uri: skillFile, etag: skillStat.etag }); + // Watch the skill subdirectory directly for SKILL.md content changes. + // The recursive watcher on rootUri is sufficient for structural changes + // (skill directories added/deleted), but on some platforms (e.g. macOS VMs) + // a recursive watcher may not reliably fire for content-only changes deep + // in the tree. Adding an explicit non-recursive watcher on each skill's + // subdirectory ensures SKILL.md modifications are always detected. + addWatch(watchRootUris, child.resource, false, skillFile); } } catch { // SKILL.md missing — skip this skill directory. diff --git a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts index 81cb56235c12c..fedc197491654 100644 --- a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts @@ -435,12 +435,18 @@ suite('SessionCustomizationDiscovery', () => { assert.strictEqual(watched.get(URI.joinPath(workspace, '.claude').toString()), false); assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'agents').toString()), false); assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'skills').toString()), true); + // Each discovered skill subdirectory is also watched non-recursively so that + // content changes to SKILL.md are detected even when the recursive watcher + // on the skills root does not fire reliably (e.g. on macOS VMs). + assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'skills', 'bar').toString()), false); assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'instructions').toString()), true); assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'hooks').toString()), true); assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot').toString()), false); assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'agents').toString()), false); assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'skills').toString()), true); + assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'skills', 'copilot-user-skill').toString()), false); assert.strictEqual(watched.get(URI.joinPath(userHome, '.agents', 'skills').toString()), true); + assert.strictEqual(watched.get(URI.joinPath(userHome, '.agents', 'skills', 'user-skill').toString()), false); assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'instructions').toString()), true); assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'hooks').toString()), true); }); @@ -518,6 +524,32 @@ suite('SessionCustomizationDiscovery', () => { assert.strictEqual(changeCount, 1, 'expected onDidChange to fire when an existing agent file is modified'); }); + test('fires onDidChange when an existing SKILL.md file is modified', async () => { + // Skill files live two levels deep (skillsDir/skill-name/SKILL.md), so a + // recursive watcher on the skills root is the primary detection mechanism. + // An explicit non-recursive watcher on each skill subdirectory is also added + // as a more reliable secondary trigger for content-only changes. + await seed('/workspace/.github/skills/my-skill/SKILL.md', 'skill body'); + + const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome, URI.file)); + await discovery.scan(CancellationToken.None); + await timeout(50); + + let changeCount = 0; + const fired = new DeferredPromise(); + disposables.add(discovery.onDidChange(() => { + changeCount++; + fired.complete(); + })); + + // Overwrite the skill file to produce an UPDATED event via the non-recursive + // watcher on the skill subdirectory (`my-skill/`). + await seed('/workspace/.github/skills/my-skill/SKILL.md', 'skill body (updated)'); + await raceTimeout(fired.p, 500); + + assert.strictEqual(changeCount, 1, 'expected onDidChange to fire when an existing SKILL.md is modified'); + }); + test('fires onDidChange when an existing agent file is deleted under a non-recursively watched root', async () => { const agentUri = await seed('/workspace/.github/agents/foo.agent.md', 'workspace agent'); // Seed a second agent so the parent directory still exists after the deletion.