This commit is contained in:
394
packages/agent/tests/skill-creator.test.ts
Normal file
394
packages/agent/tests/skill-creator.test.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
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 { generateSkillMarkdown, detectWorkflowPattern, type SkillTemplate } from '../src/skill-creator.js';
|
||||
import { shouldSuggestCapture } from '../src/workflow-capture.js';
|
||||
import { createSkillTools } from '../src/skill-tools.js';
|
||||
|
||||
// ── generateSkillMarkdown ───────────────────────────────────────────────
|
||||
|
||||
describe('generateSkillMarkdown', () => {
|
||||
it('produces valid SKILL.md with frontmatter', () => {
|
||||
const template: SkillTemplate = {
|
||||
name: 'my-research-workflow',
|
||||
description: 'Deep research on a topic with source synthesis',
|
||||
triggerPatterns: ['research this topic', 'investigate'],
|
||||
steps: [
|
||||
'Search for sources',
|
||||
'Read top 5 results',
|
||||
'Synthesize findings',
|
||||
'Save to memory',
|
||||
],
|
||||
tools: ['web_search', 'web_fetch', 'save_memory'],
|
||||
category: 'research',
|
||||
};
|
||||
|
||||
const md = generateSkillMarkdown(template);
|
||||
|
||||
// Valid frontmatter delimiters
|
||||
expect(md).toMatch(/^---\n/);
|
||||
expect(md).toMatch(/\n---\n/);
|
||||
|
||||
// Name in frontmatter (kebab-case)
|
||||
expect(md).toContain('name: my-research-workflow');
|
||||
|
||||
// Description in frontmatter
|
||||
expect(md).toContain('description: Deep research on a topic with source synthesis');
|
||||
|
||||
// Heading
|
||||
expect(md).toContain('# my-research-workflow');
|
||||
|
||||
// Steps
|
||||
expect(md).toContain('1. Search for sources');
|
||||
expect(md).toContain('2. Read top 5 results');
|
||||
expect(md).toContain('3. Synthesize findings');
|
||||
expect(md).toContain('4. Save to memory');
|
||||
|
||||
// Tools
|
||||
expect(md).toContain('- web_search');
|
||||
expect(md).toContain('- web_fetch');
|
||||
expect(md).toContain('- save_memory');
|
||||
|
||||
// Category
|
||||
expect(md).toContain('research');
|
||||
|
||||
// Trigger patterns
|
||||
expect(md).toContain('research this topic');
|
||||
expect(md).toContain('investigate');
|
||||
});
|
||||
|
||||
it('normalizes name to kebab-case', () => {
|
||||
const template: SkillTemplate = {
|
||||
name: 'My Cool Workflow!!!',
|
||||
description: 'Test',
|
||||
triggerPatterns: [],
|
||||
steps: ['Do something'],
|
||||
tools: [],
|
||||
category: 'general',
|
||||
};
|
||||
|
||||
const md = generateSkillMarkdown(template);
|
||||
expect(md).toContain('name: my-cool-workflow');
|
||||
});
|
||||
|
||||
it('handles empty tools array gracefully', () => {
|
||||
const template: SkillTemplate = {
|
||||
name: 'simple-skill',
|
||||
description: 'No tools needed',
|
||||
triggerPatterns: [],
|
||||
steps: ['Think about the problem', 'Respond clearly'],
|
||||
tools: [],
|
||||
category: 'general',
|
||||
};
|
||||
|
||||
const md = generateSkillMarkdown(template);
|
||||
expect(md).not.toContain('## Tools Used');
|
||||
expect(md).toContain('1. Think about the problem');
|
||||
expect(md).toContain('2. Respond clearly');
|
||||
});
|
||||
|
||||
it('handles empty triggerPatterns gracefully', () => {
|
||||
const template: SkillTemplate = {
|
||||
name: 'no-triggers',
|
||||
description: 'No trigger patterns',
|
||||
triggerPatterns: [],
|
||||
steps: ['Step one'],
|
||||
tools: ['bash'],
|
||||
category: 'coding',
|
||||
};
|
||||
|
||||
const md = generateSkillMarkdown(template);
|
||||
expect(md).not.toContain('## Trigger Patterns');
|
||||
});
|
||||
});
|
||||
|
||||
// ── detectWorkflowPattern ───────────────────────────────────────────────
|
||||
|
||||
describe('detectWorkflowPattern', () => {
|
||||
it('finds repeated tool patterns', () => {
|
||||
// Simulate a session with repeated web_search -> web_fetch -> save_memory
|
||||
const messages = [
|
||||
{ role: 'user', content: 'Research topic A' },
|
||||
{ role: 'assistant', content: 'Found info', toolsUsed: ['web_search', 'web_fetch', 'save_memory'] },
|
||||
{ role: 'user', content: 'Research topic B' },
|
||||
{ role: 'assistant', content: 'Found more', toolsUsed: ['web_search', 'web_fetch', 'save_memory'] },
|
||||
{ role: 'user', content: 'Research topic C' },
|
||||
{ role: 'assistant', content: 'Even more', toolsUsed: ['web_search', 'web_fetch', 'save_memory'] },
|
||||
];
|
||||
|
||||
const result = detectWorkflowPattern(messages);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.tools).toContain('web_search');
|
||||
expect(result!.tools).toContain('web_fetch');
|
||||
expect(result!.tools).toContain('save_memory');
|
||||
expect(result!.steps.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('returns null for unique workflows (no repetition)', () => {
|
||||
const messages = [
|
||||
{ role: 'user', content: 'Do something unique' },
|
||||
{ role: 'assistant', content: 'Done', toolsUsed: ['bash'] },
|
||||
{ role: 'user', content: 'Something else' },
|
||||
{ role: 'assistant', content: 'Done again', toolsUsed: ['read_file'] },
|
||||
{ role: 'user', content: 'And another' },
|
||||
{ role: 'assistant', content: 'More done', toolsUsed: ['write_file'] },
|
||||
];
|
||||
|
||||
const result = detectWorkflowPattern(messages);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for too few messages', () => {
|
||||
const messages = [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi', toolsUsed: ['web_search'] },
|
||||
];
|
||||
|
||||
const result = detectWorkflowPattern(messages);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no tools are used', () => {
|
||||
const messages = [
|
||||
{ role: 'user', content: 'Just chatting' },
|
||||
{ role: 'assistant', content: 'Sure' },
|
||||
{ role: 'user', content: 'More chat' },
|
||||
{ role: 'assistant', content: 'Indeed' },
|
||||
{ role: 'user', content: 'And more' },
|
||||
{ role: 'assistant', content: 'Yes' },
|
||||
];
|
||||
|
||||
const result = detectWorkflowPattern(messages);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('infers category from tool names', () => {
|
||||
const messages = [
|
||||
{ role: 'user', content: 'Research A' },
|
||||
{ role: 'assistant', content: 'Ok', toolsUsed: ['web_search', 'web_fetch', 'save_memory'] },
|
||||
{ role: 'user', content: 'Research B' },
|
||||
{ role: 'assistant', content: 'Ok', toolsUsed: ['web_search', 'web_fetch', 'save_memory'] },
|
||||
{ role: 'user', content: 'Research C' },
|
||||
{ role: 'assistant', content: 'Ok', toolsUsed: ['web_search', 'web_fetch', 'save_memory'] },
|
||||
];
|
||||
|
||||
const result = detectWorkflowPattern(messages);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.category).toBe('research');
|
||||
});
|
||||
});
|
||||
|
||||
// ── shouldSuggestCapture ────────────────────────────────────────────────
|
||||
|
||||
describe('shouldSuggestCapture', () => {
|
||||
it('suggests capture when pattern appears 3+ times across sessions', () => {
|
||||
const currentMessages = [
|
||||
{ role: 'user', content: 'Do research', toolsUsed: undefined as string[] | undefined },
|
||||
{ role: 'assistant', content: 'Done', toolsUsed: ['web_search', 'web_fetch', 'save_memory'] },
|
||||
];
|
||||
|
||||
const sessionHistory = [
|
||||
{ toolSequence: ['web_search', 'web_fetch', 'save_memory'] },
|
||||
{ toolSequence: ['web_search', 'web_fetch', 'save_memory'] },
|
||||
{ toolSequence: ['web_search', 'web_fetch', 'save_memory'] },
|
||||
];
|
||||
|
||||
const result = shouldSuggestCapture({
|
||||
messages: currentMessages,
|
||||
sessionHistory,
|
||||
});
|
||||
|
||||
expect(result.suggest).toBe(true);
|
||||
expect(result.pattern).toBeDefined();
|
||||
expect(result.reason).toBeDefined();
|
||||
expect(result.reason).toContain('sessions');
|
||||
});
|
||||
|
||||
it('does not suggest when fewer than 3 repetitions', () => {
|
||||
const currentMessages = [
|
||||
{ role: 'user', content: 'Research' },
|
||||
{ role: 'assistant', content: 'Done', toolsUsed: ['web_search', 'web_fetch', 'save_memory'] },
|
||||
];
|
||||
|
||||
// Only 1 historical match (need 2+ for 3+ total)
|
||||
const sessionHistory = [
|
||||
{ toolSequence: ['web_search', 'web_fetch', 'save_memory'] },
|
||||
{ toolSequence: ['bash', 'read_file', 'write_file'] },
|
||||
];
|
||||
|
||||
const result = shouldSuggestCapture({
|
||||
messages: currentMessages,
|
||||
sessionHistory,
|
||||
});
|
||||
|
||||
expect(result.suggest).toBe(false);
|
||||
});
|
||||
|
||||
it('does not suggest for very short tool sequences', () => {
|
||||
const currentMessages = [
|
||||
{ role: 'user', content: 'Quick question' },
|
||||
{ role: 'assistant', content: 'Answer', toolsUsed: ['bash'] },
|
||||
];
|
||||
|
||||
const sessionHistory = [
|
||||
{ toolSequence: ['bash'] },
|
||||
{ toolSequence: ['bash'] },
|
||||
{ toolSequence: ['bash'] },
|
||||
];
|
||||
|
||||
const result = shouldSuggestCapture({
|
||||
messages: currentMessages,
|
||||
sessionHistory,
|
||||
});
|
||||
|
||||
// Only 1 tool — too short to be a meaningful workflow
|
||||
expect(result.suggest).toBe(false);
|
||||
});
|
||||
|
||||
it('does not suggest when session history is empty', () => {
|
||||
const currentMessages = [
|
||||
{ role: 'user', content: 'Research' },
|
||||
{ role: 'assistant', content: 'Done', toolsUsed: ['web_search', 'web_fetch', 'save_memory'] },
|
||||
];
|
||||
|
||||
const result = shouldSuggestCapture({
|
||||
messages: currentMessages,
|
||||
sessionHistory: [],
|
||||
});
|
||||
|
||||
expect(result.suggest).toBe(false);
|
||||
});
|
||||
|
||||
it('includes a pre-filled pattern template when suggesting', () => {
|
||||
const currentMessages = [
|
||||
{ role: 'user', content: 'Research X' },
|
||||
{ role: 'assistant', content: 'Done', toolsUsed: ['web_search', 'web_fetch', 'save_memory'] },
|
||||
];
|
||||
|
||||
const sessionHistory = [
|
||||
{ toolSequence: ['web_search', 'web_fetch', 'save_memory'] },
|
||||
{ toolSequence: ['web_search', 'web_fetch', 'save_memory'] },
|
||||
{ toolSequence: ['web_search', 'web_fetch', 'save_memory'] },
|
||||
];
|
||||
|
||||
const result = shouldSuggestCapture({
|
||||
messages: currentMessages,
|
||||
sessionHistory,
|
||||
});
|
||||
|
||||
expect(result.suggest).toBe(true);
|
||||
expect(result.pattern).toBeDefined();
|
||||
expect(result.pattern!.name).toBeDefined();
|
||||
expect(result.pattern!.tools.length).toBeGreaterThan(0);
|
||||
expect(result.pattern!.steps.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── create_skill tool (structured input) ────────────────────────────────
|
||||
|
||||
describe('create_skill tool — structured input', () => {
|
||||
let tmpDir: string;
|
||||
let skillsDir: string;
|
||||
let tools: ReturnType<typeof createSkillTools>;
|
||||
let reloadCount: number;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-skill-creator-test-'));
|
||||
skillsDir = path.join(tmpDir, 'skills');
|
||||
reloadCount = 0;
|
||||
tools = createSkillTools({
|
||||
waggleHome: tmpDir,
|
||||
onSkillsChanged: () => { reloadCount++; },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function run(name: string, args: Record<string, unknown> = {}) {
|
||||
const tool = tools.find(t => t.name === name);
|
||||
if (!tool) throw new Error(`Tool ${name} not found`);
|
||||
return tool.execute(args);
|
||||
}
|
||||
|
||||
it('creates skill from raw content (backward compatible)', async () => {
|
||||
const result = await run('create_skill', {
|
||||
name: 'test-skill',
|
||||
content: '# Test Skill\nDo something useful.',
|
||||
});
|
||||
|
||||
expect(result).toContain('Created skill "test-skill"');
|
||||
expect(fs.existsSync(path.join(skillsDir, 'test-skill.md'))).toBe(true);
|
||||
expect(reloadCount).toBe(1);
|
||||
});
|
||||
|
||||
it('creates skill from structured template (description + steps)', async () => {
|
||||
const result = await run('create_skill', {
|
||||
name: 'research-flow',
|
||||
description: 'Research workflow for deep investigation',
|
||||
steps: ['Search for sources', 'Read top results', 'Synthesize findings'],
|
||||
tools: ['web_search', 'web_fetch'],
|
||||
category: 'research',
|
||||
});
|
||||
|
||||
expect(result).toContain('Created skill "research-flow"');
|
||||
expect(fs.existsSync(path.join(skillsDir, 'research-flow.md'))).toBe(true);
|
||||
|
||||
// Verify generated content has proper structure
|
||||
const content = fs.readFileSync(path.join(skillsDir, 'research-flow.md'), 'utf-8');
|
||||
expect(content).toContain('---');
|
||||
expect(content).toContain('name: research-flow');
|
||||
expect(content).toContain('description: Research workflow');
|
||||
expect(content).toContain('1. Search for sources');
|
||||
expect(content).toContain('2. Read top results');
|
||||
expect(content).toContain('- web_search');
|
||||
expect(reloadCount).toBe(1);
|
||||
});
|
||||
|
||||
it('returns error when neither content nor structured input provided', async () => {
|
||||
const result = await run('create_skill', {
|
||||
name: 'empty-skill',
|
||||
});
|
||||
|
||||
expect(result).toContain('Error');
|
||||
expect(result).toContain('content');
|
||||
});
|
||||
|
||||
it('returns error for structured input without steps', async () => {
|
||||
const result = await run('create_skill', {
|
||||
name: 'no-steps',
|
||||
description: 'A skill without steps',
|
||||
steps: [],
|
||||
});
|
||||
|
||||
expect(result).toContain('Error');
|
||||
});
|
||||
|
||||
it('raw content takes priority over structured input', async () => {
|
||||
const rawContent = '# Custom Content\nThis is raw.';
|
||||
const result = await run('create_skill', {
|
||||
name: 'priority-test',
|
||||
content: rawContent,
|
||||
description: 'Should be ignored',
|
||||
steps: ['Should be ignored'],
|
||||
});
|
||||
|
||||
expect(result).toContain('Created skill "priority-test"');
|
||||
const saved = fs.readFileSync(path.join(skillsDir, 'priority-test.md'), 'utf-8');
|
||||
// P5/D4(iii): raw content is preserved in the body beneath a stamped
|
||||
// provenance frontmatter block (structured input still ignored).
|
||||
expect(saved).toContain('This is raw.');
|
||||
expect(saved).toContain('initiator: agent');
|
||||
expect(saved).not.toContain('Should be ignored');
|
||||
});
|
||||
|
||||
it('rejects invalid names', async () => {
|
||||
const result = await run('create_skill', {
|
||||
name: '../etc/passwd',
|
||||
content: 'malicious',
|
||||
});
|
||||
expect(result).toContain('Error');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user