Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
392 lines
12 KiB
JavaScript
392 lines
12 KiB
JavaScript
/**
|
|
* SkillsRegistryService Tests — ADR-099 S05: Runtime Skill Injection
|
|
*
|
|
* Tests:
|
|
* - loadAgentSkills: main entry point — fetch, format, cache
|
|
* - fetchSkillsByIds: explicit skill_ids selection
|
|
* - fetchSkillsByCategories: auto-match by category
|
|
* - fetchSkillsByTags: auto-match by tags overlap
|
|
* - formatSkillInstructions: text formatting for system prompt
|
|
* - Token budget enforcement (max 2000 tokens ≈ 8000 chars)
|
|
* - Session-level caching (TTL-based, key by agent config)
|
|
* - clearCache: manual cache invalidation
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
|
|
// ===== MOCKS =====
|
|
|
|
const mockDbAll = vi.fn();
|
|
const mockDbGet = vi.fn();
|
|
const mockIsPostgres = vi.fn(() => false);
|
|
|
|
vi.mock('../../database/connection', () => ({
|
|
dbAll: (...args) => mockDbAll(...args),
|
|
dbGet: (...args) => mockDbGet(...args),
|
|
isPostgres: () => mockIsPostgres(),
|
|
}));
|
|
|
|
vi.mock('../../utils/logger', () => ({
|
|
apiLogger: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// ===== FIXTURES =====
|
|
|
|
const makeSkillRow = (id, overrides = {}) => ({
|
|
id,
|
|
data: JSON.stringify({
|
|
name: `skill_${id}`,
|
|
display_name: `Skill ${id}`,
|
|
description: `Description for skill ${id}`,
|
|
category: 'data',
|
|
method: 'GET',
|
|
endpoint: `/api/v3/skill_${id}`,
|
|
is_active: true,
|
|
tags: ['tag1', 'tag2'],
|
|
...overrides,
|
|
}),
|
|
});
|
|
|
|
const makeAgentConfig = (overrides = {}) => ({
|
|
name: 'Test Agent',
|
|
row_id: 42,
|
|
...overrides,
|
|
});
|
|
|
|
// ===== IMPORT AFTER MOCKS =====
|
|
|
|
let loadAgentSkills, formatSkillInstructions, clearCache;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
vi.resetModules();
|
|
|
|
// Re-import after each reset to get fresh module state (clear in-memory cache)
|
|
const mod = await import('../SkillsRegistryService.js');
|
|
loadAgentSkills = mod.loadAgentSkills;
|
|
formatSkillInstructions = mod.formatSkillInstructions;
|
|
clearCache = mod.clearCache;
|
|
});
|
|
|
|
// ===== TESTS =====
|
|
|
|
describe('SkillsRegistryService', () => {
|
|
|
|
// ── AC1: Resolves relevant skills on execution start ──
|
|
|
|
describe('loadAgentSkills()', () => {
|
|
it('returns empty string when agentConfig is null', async () => {
|
|
const result = await loadAgentSkills(null);
|
|
expect(result).toBe('');
|
|
expect(mockDbAll).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns empty string when agentConfig has no skill configuration', async () => {
|
|
mockDbAll.mockResolvedValue([]);
|
|
const result = await loadAgentSkills(makeAgentConfig());
|
|
expect(result).toBe('');
|
|
});
|
|
|
|
it('fetches skills by explicit skill_ids when provided', async () => {
|
|
const rows = [makeSkillRow(1), makeSkillRow(2)];
|
|
mockDbAll.mockResolvedValue(rows);
|
|
|
|
const agentConfig = makeAgentConfig({ skill_ids: [1, 2] });
|
|
const result = await loadAgentSkills(agentConfig);
|
|
|
|
expect(mockDbAll).toHaveBeenCalledOnce();
|
|
expect(result).toContain('skill_1');
|
|
expect(result).toContain('skill_2');
|
|
});
|
|
|
|
it('fetches skills by skill_categories when no skill_ids', async () => {
|
|
const rows = [makeSkillRow(10, { category: 'workspace' })];
|
|
mockDbAll.mockResolvedValue(rows);
|
|
|
|
const agentConfig = makeAgentConfig({ skill_categories: ['workspace'] });
|
|
const result = await loadAgentSkills(agentConfig);
|
|
|
|
expect(mockDbAll).toHaveBeenCalledOnce();
|
|
expect(result).toContain('skill_10');
|
|
});
|
|
|
|
it('fetches skills by skill_tags when no skill_ids or categories', async () => {
|
|
const rows = [makeSkillRow(20, { tags: ['testing', 'qa'] })];
|
|
mockDbAll.mockResolvedValue(rows);
|
|
|
|
const agentConfig = makeAgentConfig({ skill_tags: ['testing'] });
|
|
const result = await loadAgentSkills(agentConfig);
|
|
|
|
expect(mockDbAll).toHaveBeenCalledOnce();
|
|
expect(result).toContain('skill_20');
|
|
});
|
|
|
|
it('returns empty string when no skills match', async () => {
|
|
mockDbAll.mockResolvedValue([]);
|
|
|
|
const agentConfig = makeAgentConfig({ skill_ids: [999] });
|
|
const result = await loadAgentSkills(agentConfig);
|
|
|
|
expect(result).toBe('');
|
|
});
|
|
});
|
|
|
|
// ── AC3: Skill selection based on agent config ──
|
|
|
|
describe('skill selection priority', () => {
|
|
it('prefers skill_ids over skill_categories', async () => {
|
|
const rows = [makeSkillRow(1)];
|
|
mockDbAll.mockResolvedValue(rows);
|
|
|
|
const agentConfig = makeAgentConfig({
|
|
skill_ids: [1],
|
|
skill_categories: ['workspace'],
|
|
});
|
|
await loadAgentSkills(agentConfig);
|
|
|
|
// skill_ids query uses IN clause with specific IDs
|
|
const [query] = mockDbAll.mock.calls[0];
|
|
expect(query).toMatch(/IN \(/);
|
|
});
|
|
|
|
it('prefers skill_categories over skill_tags', async () => {
|
|
const rows = [makeSkillRow(5, { category: 'tables' })];
|
|
mockDbAll.mockResolvedValue(rows);
|
|
|
|
const agentConfig = makeAgentConfig({
|
|
skill_categories: ['tables'],
|
|
skill_tags: ['some-tag'],
|
|
});
|
|
await loadAgentSkills(agentConfig);
|
|
|
|
const [query] = mockDbAll.mock.calls[0];
|
|
// categories query filters by category field
|
|
expect(query).toMatch(/category/i);
|
|
});
|
|
});
|
|
|
|
// ── AC4: Token budget respected — max 2000 tokens ──
|
|
|
|
describe('token budget enforcement', () => {
|
|
it('truncates skills to stay within 2000-token budget', async () => {
|
|
// Create many skills with long descriptions
|
|
const longDesc = 'X'.repeat(500);
|
|
const rows = Array.from({ length: 50 }, (_, i) =>
|
|
makeSkillRow(i + 1, { description: longDesc })
|
|
);
|
|
mockDbAll.mockResolvedValue(rows);
|
|
|
|
const agentConfig = makeAgentConfig({ skill_ids: Array.from({ length: 50 }, (_, i) => i + 1) });
|
|
const result = await loadAgentSkills(agentConfig);
|
|
|
|
// 2000 tokens * 4 chars/token = 8000 chars max
|
|
const MAX_CHARS = 2000 * 4;
|
|
expect(result.length).toBeLessThanOrEqual(MAX_CHARS);
|
|
});
|
|
|
|
it('includes all skills when they fit within budget', async () => {
|
|
const rows = [
|
|
makeSkillRow(1, { description: 'Short desc 1' }),
|
|
makeSkillRow(2, { description: 'Short desc 2' }),
|
|
];
|
|
mockDbAll.mockResolvedValue(rows);
|
|
|
|
const agentConfig = makeAgentConfig({ skill_ids: [1, 2] });
|
|
const result = await loadAgentSkills(agentConfig);
|
|
|
|
expect(result).toContain('skill_1');
|
|
expect(result).toContain('skill_2');
|
|
});
|
|
});
|
|
|
|
// ── AC2: Skill instructions injected after main_instructions ──
|
|
|
|
describe('formatSkillInstructions()', () => {
|
|
it('returns empty string for empty skills array', () => {
|
|
expect(formatSkillInstructions([])).toBe('');
|
|
});
|
|
|
|
it('formats skills with name, display_name, description', () => {
|
|
const skills = [{
|
|
name: 'list_tables',
|
|
display_name: 'List Tables',
|
|
description: 'Get all tables in the workspace',
|
|
category: 'workspace',
|
|
method: 'GET',
|
|
endpoint: '/api/v3/tables',
|
|
}];
|
|
|
|
const result = formatSkillInstructions(skills);
|
|
|
|
expect(result).toContain('list_tables');
|
|
expect(result).toContain('List Tables');
|
|
expect(result).toContain('Get all tables in the workspace');
|
|
});
|
|
|
|
it('includes a section header for skill instructions', () => {
|
|
const skills = [{
|
|
name: 'test_skill',
|
|
display_name: 'Test Skill',
|
|
description: 'Test description',
|
|
category: 'test',
|
|
}];
|
|
|
|
const result = formatSkillInstructions(skills);
|
|
expect(result).toMatch(/## (Skills|Available Skills|Skill Instructions)/i);
|
|
});
|
|
|
|
it('formats multiple skills as a list', () => {
|
|
const skills = [
|
|
{ name: 'skill_a', display_name: 'Skill A', description: 'Desc A', category: 'data' },
|
|
{ name: 'skill_b', display_name: 'Skill B', description: 'Desc B', category: 'tables' },
|
|
];
|
|
|
|
const result = formatSkillInstructions(skills);
|
|
expect(result).toContain('skill_a');
|
|
expect(result).toContain('skill_b');
|
|
});
|
|
|
|
it('skips inactive skills (is_active = false)', () => {
|
|
const skills = [
|
|
{ name: 'active_skill', display_name: 'Active', description: 'Works', category: 'data', is_active: true },
|
|
{ name: 'inactive_skill', display_name: 'Inactive', description: 'Disabled', category: 'data', is_active: false },
|
|
];
|
|
|
|
const result = formatSkillInstructions(skills);
|
|
expect(result).toContain('active_skill');
|
|
expect(result).not.toContain('inactive_skill');
|
|
});
|
|
});
|
|
|
|
// ── AC5: Skills cached per agent session ──
|
|
|
|
describe('session-level caching', () => {
|
|
it('fetches skills from DB on first call', async () => {
|
|
const rows = [makeSkillRow(1)];
|
|
mockDbAll.mockResolvedValue(rows);
|
|
|
|
const agentConfig = makeAgentConfig({ skill_ids: [1] });
|
|
await loadAgentSkills(agentConfig);
|
|
|
|
expect(mockDbAll).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('returns cached result on second call with same agent config', async () => {
|
|
const rows = [makeSkillRow(1)];
|
|
mockDbAll.mockResolvedValue(rows);
|
|
|
|
const agentConfig = makeAgentConfig({ skill_ids: [1] });
|
|
const result1 = await loadAgentSkills(agentConfig);
|
|
const result2 = await loadAgentSkills(agentConfig);
|
|
|
|
// DB should only be called once
|
|
expect(mockDbAll).toHaveBeenCalledOnce();
|
|
expect(result1).toBe(result2);
|
|
});
|
|
|
|
it('fetches from DB again for different agent configs', async () => {
|
|
mockDbAll.mockResolvedValue([makeSkillRow(1)]);
|
|
|
|
const agentA = makeAgentConfig({ row_id: 1, skill_ids: [1] });
|
|
const agentB = makeAgentConfig({ row_id: 2, skill_ids: [2] });
|
|
|
|
await loadAgentSkills(agentA);
|
|
await loadAgentSkills(agentB);
|
|
|
|
expect(mockDbAll).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('clearCache() removes all cached entries', async () => {
|
|
const rows = [makeSkillRow(1)];
|
|
mockDbAll.mockResolvedValue(rows);
|
|
|
|
const agentConfig = makeAgentConfig({ skill_ids: [1] });
|
|
|
|
await loadAgentSkills(agentConfig);
|
|
expect(mockDbAll).toHaveBeenCalledOnce();
|
|
|
|
clearCache();
|
|
|
|
await loadAgentSkills(agentConfig);
|
|
expect(mockDbAll).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('re-fetches after TTL expires', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const rows = [makeSkillRow(1)];
|
|
mockDbAll.mockResolvedValue(rows);
|
|
|
|
const agentConfig = makeAgentConfig({ skill_ids: [1] });
|
|
|
|
await loadAgentSkills(agentConfig);
|
|
expect(mockDbAll).toHaveBeenCalledOnce();
|
|
|
|
// Advance time past 5-minute TTL
|
|
vi.advanceTimersByTime(6 * 60 * 1000);
|
|
|
|
await loadAgentSkills(agentConfig);
|
|
expect(mockDbAll).toHaveBeenCalledTimes(2);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
});
|
|
|
|
// ── PostgreSQL vs SQLite ──
|
|
|
|
describe('database adapter compatibility', () => {
|
|
it('uses PostgreSQL syntax when isPostgres() returns true', async () => {
|
|
mockIsPostgres.mockReturnValue(true);
|
|
mockDbAll.mockResolvedValue([makeSkillRow(1)]);
|
|
|
|
const agentConfig = makeAgentConfig({ skill_ids: [1] });
|
|
await loadAgentSkills(agentConfig);
|
|
|
|
const [query] = mockDbAll.mock.calls[0];
|
|
// PostgreSQL uses $1, $2 placeholders
|
|
expect(query).toMatch(/\$\d/);
|
|
});
|
|
|
|
it('uses SQLite syntax when isPostgres() returns false', async () => {
|
|
mockIsPostgres.mockReturnValue(false);
|
|
mockDbAll.mockResolvedValue([makeSkillRow(1)]);
|
|
|
|
const agentConfig = makeAgentConfig({ skill_ids: [1] });
|
|
await loadAgentSkills(agentConfig);
|
|
|
|
const [query] = mockDbAll.mock.calls[0];
|
|
// SQLite uses ? placeholders
|
|
expect(query).toMatch(/\?/);
|
|
});
|
|
});
|
|
|
|
// ── Error handling ──
|
|
|
|
describe('error handling', () => {
|
|
it('returns empty string and logs warning when DB query fails', async () => {
|
|
mockDbAll.mockRejectedValue(new Error('DB connection error'));
|
|
|
|
const agentConfig = makeAgentConfig({ skill_ids: [1] });
|
|
const result = await loadAgentSkills(agentConfig);
|
|
|
|
expect(result).toBe('');
|
|
});
|
|
|
|
it('handles malformed JSON in skill data row gracefully', async () => {
|
|
mockDbAll.mockResolvedValue([
|
|
{ id: 1, data: 'invalid json{' },
|
|
makeSkillRow(2),
|
|
]);
|
|
|
|
const agentConfig = makeAgentConfig({ skill_ids: [1, 2] });
|
|
// Should not throw, should return valid skills
|
|
const result = await loadAgentSkills(agentConfig);
|
|
expect(result).toContain('skill_2');
|
|
});
|
|
});
|
|
});
|