@@ -1098,6 +1098,314 @@ Instructions for the new skill.`;
10981098 } ) ;
10991099 } ) ;
11001100
1101+ describe ( 'maybeRefresh SKILL.md file mtime detection' , ( ) => {
1102+ /**
1103+ * Helper to mock stat with separate mtimes for directories vs files.
1104+ * This simulates the real filesystem behavior where editing a file's content
1105+ * updates the file's mtime but not its parent directory's mtime.
1106+ */
1107+ function mockSplitMtimeStat ( filesystem : MockSkillSource , getDirMtime : ( ) => Date , getFileMtime : ( ) => Date ) {
1108+ ( filesystem . stat as ReturnType < typeof vi . fn > ) . mockImplementation (
1109+ async ( path : string ) : Promise < SkillSourceStat > => {
1110+ const exists = await filesystem . exists ( path ) ;
1111+ if ( ! exists ) {
1112+ throw new Error ( `ENOENT: no such file or directory, stat '${ path } '` ) ;
1113+ }
1114+ const isFile = path . endsWith ( '.md' ) ;
1115+ const mtime = isFile ? getFileMtime ( ) : getDirMtime ( ) ;
1116+ return {
1117+ name : path . split ( '/' ) . pop ( ) || path ,
1118+ type : isFile ? ( 'file' as const ) : ( 'directory' as const ) ,
1119+ size : 0 ,
1120+ createdAt : mtime ,
1121+ modifiedAt : mtime ,
1122+ } ;
1123+ } ,
1124+ ) ;
1125+ }
1126+
1127+ it ( 'should detect SKILL.md content changes even when directory mtime unchanged' , async ( ) => {
1128+ vi . useFakeTimers ( ) ;
1129+ try {
1130+ // Separate mtimes for directories vs files
1131+ const dirMtime = new Date ( Date . now ( ) - 10000 ) ; // Old, never changes
1132+ let fileMtime = new Date ( Date . now ( ) - 10000 ) ; // Starts old, will be updated
1133+
1134+ const filesMap : Record < string , string > = {
1135+ 'skills/test-skill/SKILL.md' : `---
1136+ name: bad-name
1137+ description: A test skill with invalid name
1138+ ---
1139+ # Test Skill
1140+ Instructions here.` ,
1141+ } ;
1142+
1143+ const filesystem = createMockFilesystem ( filesMap ) ;
1144+ mockSplitMtimeStat (
1145+ filesystem ,
1146+ ( ) => dirMtime ,
1147+ ( ) => fileMtime ,
1148+ ) ;
1149+
1150+ const skills = new WorkspaceSkillsImpl ( {
1151+ source : filesystem ,
1152+ skills : [ 'skills' ] ,
1153+ validateOnLoad : true ,
1154+ checkSkillFileMtime : true , // Enable opt-in file mtime detection
1155+ } ) ;
1156+
1157+ // Initial discovery - skill has invalid name, should not be loaded
1158+ const initialList = await skills . list ( ) ;
1159+ expect ( initialList ) . toHaveLength ( 0 ) ;
1160+
1161+ // Advance past the staleness check cooldown
1162+ vi . advanceTimersByTime ( WorkspaceSkillsImpl . STALENESS_CHECK_COOLDOWN + 100 ) ;
1163+
1164+ // Fix the SKILL.md content (only file mtime changes, not directory)
1165+ const fixedSkillMd = `---
1166+ name: test-skill
1167+ description: A test skill with valid name
1168+ ---
1169+ # Test Skill
1170+ Instructions here.` ;
1171+ await filesystem . writeFile ( 'skills/test-skill/SKILL.md' , fixedSkillMd ) ;
1172+
1173+ // Update only the file mtime, directory stays old
1174+ fileMtime = new Date ( Date . now ( ) + 1000 ) ;
1175+
1176+ // maybeRefresh should detect the file change and reload
1177+ await skills . maybeRefresh ( ) ;
1178+ const afterRefresh = await skills . list ( ) ;
1179+
1180+ // With checkSkillFileMtime: true, SKILL.md file changes are detected
1181+ expect ( afterRefresh ) . toHaveLength ( 1 ) ;
1182+ expect ( afterRefresh [ 0 ] ! . name ) . toBe ( 'test-skill' ) ;
1183+ } finally {
1184+ vi . useRealTimers ( ) ;
1185+ }
1186+ } ) ;
1187+
1188+ it ( 'should detect SKILL.md content updates for existing valid skills' , async ( ) => {
1189+ vi . useFakeTimers ( ) ;
1190+ try {
1191+ const dirMtime = new Date ( Date . now ( ) - 10000 ) ;
1192+ let fileMtime = new Date ( Date . now ( ) - 10000 ) ;
1193+
1194+ const filesMap : Record < string , string > = {
1195+ 'skills/test-skill/SKILL.md' : VALID_SKILL_MD ,
1196+ } ;
1197+
1198+ const filesystem = createMockFilesystem ( filesMap ) ;
1199+ mockSplitMtimeStat (
1200+ filesystem ,
1201+ ( ) => dirMtime ,
1202+ ( ) => fileMtime ,
1203+ ) ;
1204+
1205+ const skills = new WorkspaceSkillsImpl ( {
1206+ source : filesystem ,
1207+ skills : [ 'skills' ] ,
1208+ checkSkillFileMtime : true , // Enable opt-in file mtime detection
1209+ } ) ;
1210+
1211+ // Initial discovery
1212+ const initialList = await skills . list ( ) ;
1213+ expect ( initialList ) . toHaveLength ( 1 ) ;
1214+ expect ( initialList [ 0 ] ! . description ) . toBe ( 'A test skill for unit testing' ) ;
1215+
1216+ const initialReadFileCalls = ( filesystem . readFile as ReturnType < typeof vi . fn > ) . mock . calls . length ;
1217+
1218+ // Advance past cooldown
1219+ vi . advanceTimersByTime ( WorkspaceSkillsImpl . STALENESS_CHECK_COOLDOWN + 100 ) ;
1220+
1221+ // Update SKILL.md content (description change)
1222+ const updatedSkillMd = `---
1223+ name: test-skill
1224+ description: Updated description for the skill
1225+ ---
1226+ # Test Skill
1227+ Updated instructions.` ;
1228+ await filesystem . writeFile ( 'skills/test-skill/SKILL.md' , updatedSkillMd ) ;
1229+
1230+ // Only file mtime changes
1231+ fileMtime = new Date ( Date . now ( ) + 1000 ) ;
1232+
1233+ await skills . maybeRefresh ( ) ;
1234+ const afterRefreshCalls = ( filesystem . readFile as ReturnType < typeof vi . fn > ) . mock . calls . length ;
1235+
1236+ // Should have re-read the file
1237+ expect ( afterRefreshCalls ) . toBeGreaterThan ( initialReadFileCalls ) ;
1238+
1239+ const afterRefresh = await skills . list ( ) ;
1240+ expect ( afterRefresh ) . toHaveLength ( 1 ) ;
1241+ expect ( afterRefresh [ 0 ] ! . description ) . toBe ( 'Updated description for the skill' ) ;
1242+ } finally {
1243+ vi . useRealTimers ( ) ;
1244+ }
1245+ } ) ;
1246+
1247+ it ( 'should detect new agent-created skills via directory mtime change (checkSkillFileMtime not required)' , async ( ) => {
1248+ vi . useFakeTimers ( ) ;
1249+ try {
1250+ let dirMtime = new Date ( Date . now ( ) - 10000 ) ;
1251+ let fileMtime = new Date ( Date . now ( ) - 10000 ) ;
1252+
1253+ const filesMap : Record < string , string > = {
1254+ 'skills/test-skill/SKILL.md' : VALID_SKILL_MD , // name: test-skill matches directory
1255+ } ;
1256+
1257+ const filesystem = createMockFilesystem ( filesMap ) ;
1258+ mockSplitMtimeStat (
1259+ filesystem ,
1260+ ( ) => dirMtime ,
1261+ ( ) => fileMtime ,
1262+ ) ;
1263+
1264+ // Note: checkSkillFileMtime is NOT enabled - directory mtime detection should still work
1265+ const skills = new WorkspaceSkillsImpl ( {
1266+ source : filesystem ,
1267+ skills : [ 'skills' ] ,
1268+ } ) ;
1269+
1270+ // Initial discovery
1271+ const initialList = await skills . list ( ) ;
1272+ expect ( initialList ) . toHaveLength ( 1 ) ;
1273+
1274+ // Advance past cooldown
1275+ vi . advanceTimersByTime ( WorkspaceSkillsImpl . STALENESS_CHECK_COOLDOWN + 100 ) ;
1276+
1277+ // Agent creates a new skill by writing files directly
1278+ const newSkillMd = `---
1279+ name: agent-created-skill
1280+ description: A skill created by the agent
1281+ ---
1282+ # Agent Created Skill
1283+ This skill was created programmatically.` ;
1284+ await filesystem . writeFile ( 'skills/agent-created-skill/SKILL.md' , newSkillMd ) ;
1285+
1286+ // Creating new directory updates parent directory mtime - this triggers refresh
1287+ dirMtime = new Date ( Date . now ( ) + 1000 ) ;
1288+ fileMtime = new Date ( Date . now ( ) + 1000 ) ;
1289+
1290+ await skills . maybeRefresh ( ) ;
1291+ const afterRefresh = await skills . list ( ) ;
1292+
1293+ expect ( afterRefresh ) . toHaveLength ( 2 ) ;
1294+ expect ( afterRefresh . map ( s => s . name ) . sort ( ) ) . toEqual ( [ 'agent-created-skill' , 'test-skill' ] ) ;
1295+ } finally {
1296+ vi . useRealTimers ( ) ;
1297+ }
1298+ } ) ;
1299+
1300+ it ( 'should NOT detect SKILL.md file changes by default (checkSkillFileMtime: false)' , async ( ) => {
1301+ vi . useFakeTimers ( ) ;
1302+ try {
1303+ const dirMtime = new Date ( Date . now ( ) - 10000 ) ;
1304+ let fileMtime = new Date ( Date . now ( ) - 10000 ) ;
1305+
1306+ const filesMap : Record < string , string > = {
1307+ 'skills/test-skill/SKILL.md' : `---
1308+ name: bad-name
1309+ description: A test skill with invalid name
1310+ ---
1311+ # Test Skill
1312+ Instructions here.` ,
1313+ } ;
1314+
1315+ const filesystem = createMockFilesystem ( filesMap ) ;
1316+ mockSplitMtimeStat (
1317+ filesystem ,
1318+ ( ) => dirMtime ,
1319+ ( ) => fileMtime ,
1320+ ) ;
1321+
1322+ // Default: checkSkillFileMtime is false
1323+ const skills = new WorkspaceSkillsImpl ( {
1324+ source : filesystem ,
1325+ skills : [ 'skills' ] ,
1326+ validateOnLoad : true ,
1327+ } ) ;
1328+
1329+ // Initial discovery - skill has invalid name
1330+ const initialList = await skills . list ( ) ;
1331+ expect ( initialList ) . toHaveLength ( 0 ) ;
1332+
1333+ vi . advanceTimersByTime ( WorkspaceSkillsImpl . STALENESS_CHECK_COOLDOWN + 100 ) ;
1334+
1335+ // Fix the SKILL.md content (only file mtime changes)
1336+ const fixedSkillMd = `---
1337+ name: test-skill
1338+ description: A test skill with valid name
1339+ ---
1340+ # Test Skill
1341+ Instructions here.` ;
1342+ await filesystem . writeFile ( 'skills/test-skill/SKILL.md' , fixedSkillMd ) ;
1343+ fileMtime = new Date ( Date . now ( ) + 1000 ) ;
1344+
1345+ // Without checkSkillFileMtime, this should NOT detect the change
1346+ await skills . maybeRefresh ( ) ;
1347+ const afterRefresh = await skills . list ( ) ;
1348+
1349+ // Still 0 because file mtime change was not detected
1350+ expect ( afterRefresh ) . toHaveLength ( 0 ) ;
1351+ } finally {
1352+ vi . useRealTimers ( ) ;
1353+ }
1354+ } ) ;
1355+
1356+ it ( 'should detect SKILL.md changes for direct skill paths (e.g., **/SKILL.md glob)' , async ( ) => {
1357+ vi . useFakeTimers ( ) ;
1358+ try {
1359+ // Separate mtimes for directories vs files
1360+ const dirMtime = new Date ( Date . now ( ) - 10000 ) ; // Old, never changes
1361+ let fileMtime = new Date ( Date . now ( ) - 10000 ) ; // Starts old, will be updated
1362+
1363+ const filesMap : Record < string , string > = {
1364+ 'skills/my-skill/SKILL.md' : VALID_SKILL_MD ,
1365+ } ;
1366+
1367+ const filesystem = createMockFilesystem ( filesMap ) ;
1368+ mockSplitMtimeStat (
1369+ filesystem ,
1370+ ( ) => dirMtime ,
1371+ ( ) => fileMtime ,
1372+ ) ;
1373+
1374+ // Direct skill path (simulates glob that resolved directly to a skill directory)
1375+ const skills = new WorkspaceSkillsImpl ( {
1376+ source : filesystem ,
1377+ skills : [ 'skills/my-skill' ] , // Direct skill path
1378+ validateOnLoad : false ,
1379+ checkSkillFileMtime : true ,
1380+ } ) ;
1381+
1382+ // Initial discovery
1383+ const initialList = await skills . list ( ) ;
1384+ expect ( initialList ) . toHaveLength ( 1 ) ;
1385+ expect ( initialList [ 0 ] ! . description ) . toBe ( 'A test skill for unit testing' ) ;
1386+
1387+ // Advance past the staleness check cooldown
1388+ vi . advanceTimersByTime ( WorkspaceSkillsImpl . STALENESS_CHECK_COOLDOWN + 100 ) ;
1389+
1390+ // Update the skill content (only file mtime changes, not directory)
1391+ const updatedSkillMd = VALID_SKILL_MD . replace ( 'A test skill for unit testing' , 'An updated skill description' ) ;
1392+ await filesystem . writeFile ( 'skills/my-skill/SKILL.md' , updatedSkillMd ) ;
1393+
1394+ // Update only the file mtime, directory stays old
1395+ fileMtime = new Date ( Date . now ( ) + 1000 ) ;
1396+
1397+ // maybeRefresh should detect the SKILL.md mtime change via direct skill path check
1398+ await skills . maybeRefresh ( ) ;
1399+ const afterRefresh = await skills . list ( ) ;
1400+
1401+ expect ( afterRefresh ) . toHaveLength ( 1 ) ;
1402+ expect ( afterRefresh [ 0 ] ! . description ) . toBe ( 'An updated skill description' ) ;
1403+ } finally {
1404+ vi . useRealTimers ( ) ;
1405+ }
1406+ } ) ;
1407+ } ) ;
1408+
11011409 describe ( 'read-only mode (SkillSource)' , ( ) => {
11021410 // Create a mock read-only source (no write methods)
11031411 function createMockReadOnlySource ( files : Record < string , string | Buffer > = { } ) {
0 commit comments