Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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<void>();
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.
Expand Down