import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; import { ROSTER_V2_JSON_SCHEMA, RosterV2ValidationError, parseRosterV2, renderRosterV2Yaml, validateRosterV2Semantics, } from './roster-v2.js'; const validRoster = ` version: 2 generation: 7 transport: tmux tmux: socket_name: mosaic-fleet holder_session: _holder defaults: working_directory: ~/src runtime: pi runtimes: pi: reset_command: /new agents: - name: coder0 alias: Coder 0 class: code runtime: pi provider: openai model: gpt-5.6-sol reasoning: high tool_policy: code working_directory: ~/src persistent_persona: false reset_between_tasks: true lifecycle: enabled: true desired_state: stopped launch: yolo: true `; let semanticTmp: string | undefined; afterEach(async (): Promise => { if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true }); semanticTmp = undefined; }); async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> { semanticTmp = await mkdtemp(join(tmpdir(), 'roster-v2-semantics-')); const rolesDir = join(semanticTmp, 'roles'); const overrideDir = join(semanticTmp, 'roles.local'); await mkdir(rolesDir, { recursive: true }); await mkdir(overrideDir, { recursive: true }); for (const klass of [ 'code', 'review', 'interaction', 'orchestrator', 'merge-gate', 'validator', 'team-leader', ]) { await writeFile(join(rolesDir, `${klass}.md`), `# ${klass}\n\n(\`class: ${klass}\`)\n`, 'utf8'); } return { rolesDir, overrideDir }; } function rosterWithClass(klass: string, toolPolicy = klass): string { return validRoster .replace('class: code', `class: ${klass}`) .replace('tool_policy: code', `tool_policy: ${toolPolicy}`); } describe('roster v2 semantic validation', (): void => { it.each([ ['implementer', 'code'], ['reviewer', 'review'], ['operator-interaction', 'interaction'], ])( 'canonicalizes requested alias %s while retaining requested and canonical class', async (requested: string, canonical: string) => { const dirs = await semanticDirs(); const roster = parseRosterV2(rosterWithClass(requested, requested), 'yaml'); const validated = await validateRosterV2Semantics(roster, dirs); expect(validated.agents[0]).toMatchObject({ requestedClass: requested, canonicalClass: canonical, canonicalToolPolicy: canonical, }); }, ); it.each(['worker', 'analyst', 'canary'])( 'rejects %s when no genuine custom role exists', async (klass: string) => { const dirs = await semanticDirs(); await expect( validateRosterV2Semantics(parseRosterV2(rosterWithClass(klass), 'yaml'), dirs), ).rejects.toThrow(/unresolved|readable persona/i); }, ); it('accepts a genuine custom roles.local class without protected authority', async () => { const dirs = await semanticDirs(); await writeFile(join(dirs.overrideDir, 'worker.md'), '# worker\n\n(`class: worker`)\n', 'utf8'); const validated = await validateRosterV2Semantics( parseRosterV2(rosterWithClass('worker'), 'yaml'), dirs, ); expect(validated.agents[0]?.authority).toMatchObject({ mayMerge: false, mayOrchestrate: false, }); }); it('rejects a LIBRARY-only class with no readable resolved persona', async () => { const dirs = await semanticDirs(); await writeFile( join(dirs.rolesDir, 'LIBRARY.md'), '| Persona | Purpose |\n| --- | --- |\n| phantom | Missing |\n', 'utf8', ); await expect( validateRosterV2Semantics(parseRosterV2(rosterWithClass('phantom'), 'yaml'), dirs), ).rejects.toThrow(/readable persona/i); }); it('rejects an unreadable resolved persona', async () => { const dirs = await semanticDirs(); await mkdir(join(dirs.overrideDir, 'worker.md')); await expect( validateRosterV2Semantics(parseRosterV2(rosterWithClass('worker'), 'yaml'), dirs), ).rejects.toThrow(/readable persona/i); }); it.each([ ['merge-gate', 'code'], ['validator', 'merge-gate'], ['orchestrator', 'interaction'], ['team-leader', 'orchestrator'], ['interaction', 'orchestrator'], ['code', 'merge-gate'], ['worker', 'validator'], ])( 'denies protected class/tool-policy mismatch %s with %s', async (klass: string, toolPolicy: string) => { const dirs = await semanticDirs(); if (klass === 'worker') { await writeFile( join(dirs.overrideDir, 'worker.md'), '# worker\n\n(`class: worker`)\n', 'utf8', ); } await expect( validateRosterV2Semantics(parseRosterV2(rosterWithClass(klass, toolPolicy), 'yaml'), dirs), ).rejects.toThrow(/tool policy.*must match|mismatch/i); }, ); }); describe('roster v2 structural compiler', (): void => { it('parses YAML into a normalized typed model and renders canonical YAML', (): void => { const roster = parseRosterV2(validRoster, 'yaml'); expect(roster).toEqual({ version: 2, generation: 7, transport: 'tmux', tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' }, defaults: { workingDirectory: '~/src', runtime: 'pi' }, runtimes: { pi: { resetCommand: '/new' } }, agents: [ { name: 'coder0', alias: 'Coder 0', className: 'code', runtime: 'pi', provider: 'openai', model: 'gpt-5.6-sol', reasoning: 'high', toolPolicy: 'code', workingDirectory: '~/src', persistentPersona: false, resetBetweenTasks: true, lifecycle: { enabled: true, desiredState: 'stopped' }, launch: { yolo: true }, }, ], }); expect(renderRosterV2Yaml(roster)).toBe(validRoster.trimStart()); }); it('parses JSON and produces the same normalized model', (): void => { const yaml = parseRosterV2(validRoster, 'yaml'); const json = JSON.stringify({ version: 2, generation: 7, transport: 'tmux', tmux: { socket_name: 'mosaic-fleet', holder_session: '_holder' }, defaults: { working_directory: '~/src', runtime: 'pi' }, runtimes: { pi: { reset_command: '/new' } }, agents: [ { name: 'coder0', alias: 'Coder 0', class: 'code', runtime: 'pi', provider: 'openai', model: 'gpt-5.6-sol', reasoning: 'high', tool_policy: 'code', working_directory: '~/src', persistent_persona: false, reset_between_tasks: true, lifecycle: { enabled: true, desired_state: 'stopped' }, launch: { yolo: true }, }, ], }); expect(parseRosterV2(json, 'json')).toEqual(yaml); }); it('sorts runtime and agent maps in the deterministic renderer', (): void => { const roster = parseRosterV2( validRoster .replace( 'runtimes:\n pi:\n reset_command: /new', 'runtimes:\n pi:\n reset_command: /new\n codex:\n reset_command: /clear', ) .replace( 'agents:\n - name: coder0', 'agents:\n - name: reviewer\n alias: Reviewer\n class: review\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: medium\n tool_policy: review\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true\n - name: coder0', ), 'yaml', ); const rendered = renderRosterV2Yaml(roster); expect(rendered.indexOf(' codex:')).toBeLessThan(rendered.indexOf(' pi:')); expect(rendered.indexOf(' - name: coder0')).toBeLessThan( rendered.indexOf(' - name: reviewer'), ); }); it.each([ ['v1 document', validRoster.replace('version: 2', 'version: 1'), /v1.*existing v1 path/i], [ 'unknown connector', `${validRoster}\nconnector:\n kind: matrix\n`, /unsupported field.*connector/i, ], [ 'remote host', validRoster.replace( 'runtime: pi\n provider', 'runtime: pi\n host: remote\n provider', ), /unsupported field.*host/i, ], [ 'secret reference', validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n secret_ref: vault://x'), /unsupported field.*secret_ref/i, ], [ 'channel override', validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n channels: discord'), /unsupported field.*channels/i, ], [ 'arbitrary command', validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n command: whoami'), /unsupported field.*command/i, ], [ 'gateway field', `${validRoster}\ngateway:\n url: https://gateway.example\n`, /unsupported field.*gateway/i, ], [ 'missing required field', validRoster.replace(' model: gpt-5.6-sol\n', ''), /model.*required/i, ], [ 'invalid type', validRoster.replace('generation: 7', 'generation: seven'), /generation.*integer/i, ], [ 'unsafe generation', validRoster.replace('generation: 7', 'generation: 9007199254740992'), /generation.*integer/i, ], [ 'duplicate names', validRoster.replace( ' - name: coder0', ' - name: coder0\n alias: Duplicate\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true\n - name: coder0', ), /duplicate agent name/i, ], ['invalid name', validRoster.replace('name: coder0', 'name: ../coder0'), /invalid agent name/i], [ 'invalid transport', validRoster.replace('transport: tmux', 'transport: matrix'), /transport.*tmux/i, ], [ 'invalid runtime', validRoster.replace('runtime: pi', 'runtime: matrix'), /runtime.*supported/i, ], [ 'invalid reasoning', validRoster.replace('reasoning: high', 'reasoning: extreme'), /reasoning.*low.*medium.*high/i, ], [ 'ambiguous socket', validRoster.replace('socket_name: mosaic-fleet', 'socket_name: default/socket'), /socket_name/i, ], [ 'agent socket override', validRoster.replace( 'runtime: pi\n provider', 'runtime: pi\n socket: another\n provider', ), /unsupported field.*socket/i, ], ])('rejects %s', (_name: string, source: string, expected: RegExp): void => { expect((): void => { parseRosterV2(source, 'yaml'); }).toThrow(expected); }); it('rejects malformed JSON as a validation error', (): void => { expect((): void => { parseRosterV2('{', 'json'); }).toThrow(RosterV2ValidationError); }); it('declares supported runtime map keys in the executable schema', (): void => { expect(ROSTER_V2_JSON_SCHEMA).toMatchObject({ properties: { runtimes: { propertyNames: { enum: ['claude', 'codex', 'opencode', 'pi'] } }, }, }); }); it('keeps the checked-in documentation schema structurally identical to the executable schema', async (): Promise => { const schemaPath = fileURLToPath( new URL('../../../../docs/fleet/reference/roster-v2.schema.json', import.meta.url), ); const documented = await readFile(schemaPath, 'utf8'); expect(JSON.parse(documented) as unknown).toEqual(ROSTER_V2_JSON_SCHEMA); }); });