import { describe, it, expect } from 'vitest'; import { PERSONAS, getPersona, listPersonas, composePersonaPrompt } from '../src/personas.js'; describe('Agent Personas', () => { it('PERSONAS catalog has 23 entries', () => { // 22 user-facing (8 universal + 14 specialists) + 1 internal review persona // (session-reviewer) used only by the IdleSessionWatcher self-evolution loop. expect(PERSONAS).toHaveLength(23); }); it('each persona has required fields', () => { for (const persona of PERSONAS) { expect(persona.id).toBeTruthy(); expect(persona.name).toBeTruthy(); expect(persona.description).toBeTruthy(); expect(persona.icon).toBeTruthy(); expect(persona.systemPrompt).toBeTruthy(); expect(persona.modelPreference).toBeTruthy(); expect(Array.isArray(persona.tools)).toBe(true); expect(persona.tools.length).toBeGreaterThan(0); expect(Array.isArray(persona.workspaceAffinity)).toBe(true); expect(Array.isArray(persona.suggestedCommands)).toBe(true); } }); it('all persona IDs are unique', () => { const ids = PERSONAS.map(p => p.id); expect(new Set(ids).size).toBe(ids.length); }); it('getPersona(id) returns the matching persona', () => { const researcher = getPersona('researcher'); expect(researcher).not.toBeNull(); expect(researcher!.name).toBe('Researcher'); expect(researcher!.tools).toContain('web_search'); }); it('getPersona(nonexistent) returns null', () => { expect(getPersona('nonexistent')).toBeNull(); expect(getPersona('')).toBeNull(); }); it('listPersonas() returns all 23 personas', () => { const list = listPersonas(); expect(list).toHaveLength(23); expect(list).not.toBe(PERSONAS); // Returns a copy }); it('each persona systemPrompt is under 4000 chars', () => { for (const persona of PERSONAS) { expect(persona.systemPrompt.length).toBeLessThan(4000); } }); it('covers the 8 original roles', () => { const ids = PERSONAS.map(p => p.id); expect(ids).toContain('researcher'); expect(ids).toContain('writer'); expect(ids).toContain('analyst'); expect(ids).toContain('coder'); expect(ids).toContain('project-manager'); expect(ids).toContain('executive-assistant'); expect(ids).toContain('sales-rep'); expect(ids).toContain('marketer'); }); it('covers the 4 new universal/orchestration personas', () => { const ids = PERSONAS.map(p => p.id); expect(ids).toContain('general-purpose'); expect(ids).toContain('planner'); expect(ids).toContain('verifier'); expect(ids).toContain('coordinator'); }); it('every persona has failurePatterns with exactly 3 entries', () => { for (const persona of PERSONAS) { expect(persona.failurePatterns).toBeDefined(); expect(persona.failurePatterns).toHaveLength(3); } }); it('every persona has disallowedTools defined (array or undefined for soft-model personas)', () => { const softModelIds = ['researcher', 'analyst']; for (const persona of PERSONAS) { if (softModelIds.includes(persona.id)) { // Soft model personas should NOT have disallowedTools expect(persona.disallowedTools, `${persona.id} should not have disallowedTools`).toBeUndefined(); } } }); it('read-only personas have isReadOnly true', () => { const readOnlyIds = ['planner', 'verifier']; for (const id of readOnlyIds) { const persona = getPersona(id); expect(persona).not.toBeNull(); expect(persona!.isReadOnly).toBe(true); } }); it('planner disallowedTools includes write_file', () => { const planner = getPersona('planner'); expect(planner).not.toBeNull(); expect(planner!.disallowedTools).toContain('write_file'); }); it('verifier disallowedTools includes write_file', () => { const verifier = getPersona('verifier'); expect(verifier).not.toBeNull(); expect(verifier!.disallowedTools).toContain('write_file'); }); it('coordinator tools has exactly 11 items', () => { const coordinator = getPersona('coordinator'); expect(coordinator).not.toBeNull(); expect(coordinator!.tools).toHaveLength(11); expect(coordinator!.tools).toContain('spawn_agent'); expect(coordinator!.tools).toContain('show_plan'); }); }); describe('Prompt composition', () => { const corePrompt = 'You are Waggle, a workspace-native AI agent with persistent memory.'; it('composePersonaPrompt appends persona after separator', () => { const persona = getPersona('researcher')!; const result = composePersonaPrompt(corePrompt, persona); expect(result).toContain(corePrompt); expect(result).toContain('---'); expect(result).toContain('Persona: Researcher'); expect(result.indexOf(corePrompt)).toBeLessThan(result.indexOf('Persona: Researcher')); }); it('gives Researcher an exact GitHub README recovery path', () => { const result = composePersonaPrompt(corePrompt, getPersona('researcher')!); expect(result).toContain('raw.githubusercontent.com'); expect(result).toContain('before declaring an evidence gap'); expect(result).toContain('every compared item'); }); it('combined prompt stays under 32000 chars', () => { for (const persona of PERSONAS) { const result = composePersonaPrompt(corePrompt, persona); expect(result.length).toBeLessThanOrEqual(32000); } }); it('persona prompt is truncated if combined exceeds limit', () => { const longCore = 'A'.repeat(30000); const persona = getPersona('researcher')!; // Set a tight limit so persona MUST be truncated (account for DOCX hint ~150 chars) const result = composePersonaPrompt(longCore, persona, 30300); expect(result.length).toBeLessThanOrEqual(30300); expect(result).toContain('[...truncated]'); }); it('null persona returns core prompt with DOCX hint appended', () => { const result = composePersonaPrompt(corePrompt, null); expect(result).toContain(corePrompt); expect(result).toContain('generate_docx'); }); }); describe('Persona field completeness', () => { for (const persona of PERSONAS) { it(`${persona.id} has tagline, bestFor, wontDo`, () => { expect(persona.tagline, `${persona.id} missing tagline`).toBeTruthy(); expect(persona.bestFor, `${persona.id} missing bestFor`).toBeDefined(); expect(persona.bestFor!.length, `${persona.id} bestFor should have 3 items`).toBe(3); expect(persona.wontDo, `${persona.id} missing wontDo`).toBeTruthy(); }); } it('all personas have failurePatterns with at least 3 entries', () => { for (const p of PERSONAS) { expect(p.failurePatterns, `${p.id} missing failurePatterns`).toBeDefined(); expect(p.failurePatterns!.length, `${p.id} needs 3+ failure patterns`).toBeGreaterThanOrEqual(3); } }); }); describe('Soft tool model', () => { it('researcher is NOT hard read-only', () => { const r = getPersona('researcher'); expect(r!.isReadOnly).toBeFalsy(); expect(r!.disallowedTools).toBeUndefined(); }); it('analyst is NOT hard read-only', () => { const a = getPersona('analyst'); expect(a!.isReadOnly).toBeFalsy(); expect(a!.disallowedTools).toBeUndefined(); }); it('planner still has hard disallowedTools', () => { const p = getPersona('planner'); expect(p!.disallowedTools).toBeDefined(); expect(p!.disallowedTools).toContain('write_file'); expect(p!.isReadOnly).toBe(true); }); it('verifier still has hard disallowedTools', () => { const v = getPersona('verifier'); expect(v!.disallowedTools).toBeDefined(); expect(v!.disallowedTools).toContain('write_file'); expect(v!.isReadOnly).toBe(true); }); it('session-reviewer is a trust-boundary persona: reads + create_skill only, no writes/exec/memory', () => { const r = getPersona('session-reviewer'); expect(r, 'session-reviewer not found').toBeTruthy(); // create_skill is the proposal vehicle and must stay in the allowlist. expect(r!.tools).toContain('create_skill'); // isReadOnly would strip create_skill, so it must be false. expect(r!.isReadOnly).toBe(false); // The critical tools ALWAYS_AVAILABLE_TOOLS re-adds must be explicitly denied. for (const t of ['save_memory', 'delete_skill', 'install_capability', 'acquire_capability', 'spawn_agent', 'bash']) { expect(r!.disallowedTools, `${t} must be disallowed`).toContain(t); } }); }); describe('New domain personas', () => { const NEW_IDS = ['support-agent', 'ops-manager', 'data-engineer', 'recruiter', 'creative-director']; for (const id of NEW_IDS) { it(`${id} exists and has complete fields`, () => { const p = getPersona(id); expect(p, `${id} not found`).toBeTruthy(); expect(p!.tagline).toBeTruthy(); expect(p!.bestFor?.length).toBe(3); expect(p!.wontDo).toBeTruthy(); expect(p!.failurePatterns?.length).toBeGreaterThanOrEqual(3); expect(p!.systemPrompt.length).toBeGreaterThan(100); expect(p!.tools.length).toBeGreaterThan(3); }); } it('total persona count is 23', () => { // 22 tiered + session-reviewer (internal self-evolution reviewer). expect(PERSONAS.length).toBe(23); }); });