Files
waggle-os/packages/agent/tests/skill-retirement.test.ts
Oleg Maslov b20b138fe4 moving
2026-09-02 10:14:22 +02:00

169 lines
5.7 KiB
TypeScript

/**
* Skills 2.0 gap F — skill usage tracking + auto-retirement.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { MindDB, ImprovementSignalStore } from '@waggle/core';
import { retireStaleSkills } from '../src/skill-retirement.js';
import {
recordSkillUsage,
loadSkillUsage,
saveSkillUsage,
forgetSkillUsage,
} from '../src/skill-usage.js';
function seedSkill(dir: string, name: string, mtimeMs: number, content = '# body'): string {
fs.mkdirSync(dir, { recursive: true });
const p = path.join(dir, `${name}.md`);
fs.writeFileSync(p, content, 'utf-8');
const mtime = new Date(mtimeMs);
fs.utimesSync(p, mtime, mtime);
return p;
}
describe('skill-usage — gap F', () => {
let waggleHome: string;
beforeEach(() => {
waggleHome = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-usage-'));
});
afterEach(() => {
fs.rmSync(waggleHome, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 100,
});
});
it('records a new skill usage and increments on repeat', () => {
const t0 = new Date('2026-01-01T00:00:00Z');
const first = recordSkillUsage(waggleHome, 'deploy', () => t0);
expect(first.count).toBe(1);
expect(first.lastUsedAt).toBe(t0.toISOString());
const t1 = new Date('2026-02-01T00:00:00Z');
const second = recordSkillUsage(waggleHome, 'deploy', () => t1);
expect(second.count).toBe(2);
expect(second.lastUsedAt).toBe(t1.toISOString());
});
it('loadSkillUsage returns {} when the file is missing', () => {
expect(loadSkillUsage(waggleHome)).toEqual({});
});
it('loadSkillUsage tolerates a corrupt index file', () => {
fs.writeFileSync(path.join(waggleHome, 'skill-usage.json'), 'not-json{', 'utf-8');
expect(loadSkillUsage(waggleHome)).toEqual({});
});
it('forgetSkillUsage removes a single entry without touching others', () => {
saveSkillUsage(waggleHome, {
a: { lastUsedAt: '2026-01-01T00:00:00.000Z', count: 1 },
b: { lastUsedAt: '2026-01-02T00:00:00.000Z', count: 3 },
});
forgetSkillUsage(waggleHome, 'a');
const idx = loadSkillUsage(waggleHome);
expect(idx.a).toBeUndefined();
expect(idx.b).toBeDefined();
});
});
describe('retireStaleSkills — gap F', () => {
let waggleHome: string;
let skillsDir: string;
let db: MindDB;
let signals: ImprovementSignalStore;
const NOW = new Date('2026-04-15T00:00:00Z');
const DAY = 24 * 60 * 60 * 1000;
beforeEach(() => {
waggleHome = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-retire-'));
skillsDir = path.join(waggleHome, 'skills');
db = new MindDB(':memory:');
signals = new ImprovementSignalStore(db);
});
afterEach(() => {
db.close();
fs.rmSync(waggleHome, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 100,
});
});
it('retires skills whose last-used is older than maxIdleDays', () => {
// Fresh skill — used 10 days ago — should NOT be retired
const fresh = seedSkill(skillsDir, 'fresh', NOW.getTime());
saveSkillUsage(waggleHome, {
fresh: { lastUsedAt: new Date(NOW.getTime() - 10 * DAY).toISOString(), count: 5 },
stale: { lastUsedAt: new Date(NOW.getTime() - 200 * DAY).toISOString(), count: 1 },
});
// Stale skill — used 200 days ago — SHOULD be retired
const stale = seedSkill(skillsDir, 'stale', NOW.getTime());
const report = retireStaleSkills(waggleHome, {
maxIdleDays: 90,
now: () => NOW,
improvementSignals: signals,
});
expect(report.scanned).toBe(2);
expect(report.retired).toEqual(['stale']);
expect(fs.existsSync(fresh)).toBe(true);
expect(fs.existsSync(stale)).toBe(false);
expect(report.archived.length).toBe(1);
expect(fs.existsSync(report.archived[0])).toBe(true);
// Usage entry for the retired skill is cleared
expect(loadSkillUsage(waggleHome).stale).toBeUndefined();
expect(loadSkillUsage(waggleHome).fresh).toBeDefined();
// Signal recorded
const recorded = signals.getByCategory('workflow_pattern').find(s => s.pattern_key === 'retire:stale');
expect(recorded).toBeDefined();
});
it('falls back to file mtime when no usage entry exists', () => {
// mtime 200 days ago, no usage entry
seedSkill(skillsDir, 'never-used', NOW.getTime() - 200 * DAY);
const report = retireStaleSkills(waggleHome, { maxIdleDays: 90, now: () => NOW });
expect(report.retired).toEqual(['never-used']);
});
it('dry_run reports but does not move files', () => {
const stalePath = seedSkill(skillsDir, 'stale', NOW.getTime() - 200 * DAY);
const report = retireStaleSkills(waggleHome, {
maxIdleDays: 90,
dryRun: true,
now: () => NOW,
});
expect(report.retired).toEqual(['stale']);
expect(report.archived.length).toBe(0);
expect(fs.existsSync(stalePath)).toBe(true); // File NOT moved
});
it('returns an empty report when the skills dir is missing', () => {
const report = retireStaleSkills(waggleHome, { maxIdleDays: 90, now: () => NOW });
expect(report.scanned).toBe(0);
expect(report.retired).toEqual([]);
});
it('keeps fresh skills untouched when all are within the window', () => {
seedSkill(skillsDir, 'a', NOW.getTime() - 5 * DAY);
seedSkill(skillsDir, 'b', NOW.getTime() - 20 * DAY);
saveSkillUsage(waggleHome, {
a: { lastUsedAt: new Date(NOW.getTime() - 5 * DAY).toISOString(), count: 1 },
b: { lastUsedAt: new Date(NOW.getTime() - 20 * DAY).toISOString(), count: 1 },
});
const report = retireStaleSkills(waggleHome, { maxIdleDays: 90, now: () => NOW });
expect(report.retired).toEqual([]);
expect(report.skipped.every(s => s.reason === 'fresh')).toBe(true);
});
});