* test(#1178): consolidate duplicated agent-roster helper into tests/helpers The "list gsd-*.md agent files, strip .md, sort" derivation was hand-duplicated across the suite (two listAgentFiles(), an identical agentFilesOnDisk(), and inline readdir blocks). Add tests/helpers/agent-roster.cjs exporting listAgentFiles(agentsDir?) and route the genuinely-identical source-roster sites through it. Semantically-different sites (installed-dest dirs, absolute-path returns, .toml-inclusive Codex rosters, full-.md-filename readers, the uniform multi-family inventory table) are left intact, each with a one-line comment. Test-only; no production code touched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(#1178): note AGENTS_DIR export is for future call sites Review nit: clarify that the currently-unused AGENTS_DIR export is intentional — available for future tests needing the canonical source agents path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
221 lines
9.4 KiB
JavaScript
221 lines
9.4 KiB
JavaScript
/**
|
|
* Model Profiles Tests
|
|
*
|
|
* Tests for MODEL_PROFILES data structure, VALID_PROFILES list,
|
|
* formatAgentToModelMapAsTable, getAgentToModelMapForProfile,
|
|
* and resolveModelInternal precedence (override > profile > default).
|
|
*/
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const {
|
|
MODEL_PROFILES,
|
|
VALID_PROFILES,
|
|
formatAgentToModelMapAsTable,
|
|
getAgentToModelMapForProfile,
|
|
} = require('../gsd-core/bin/lib/model-profiles.cjs');
|
|
|
|
const { resolveModelInternal } = require('../gsd-core/bin/lib/model-resolver.cjs');
|
|
const { createTempProject, cleanup } = require('./helpers.cjs');
|
|
const { listAgentFiles } = require('./helpers/agent-roster.cjs');
|
|
|
|
// ─── temp-project helpers ──────────────────────────────────────────────────────
|
|
|
|
function writeConfig(tmpDir, obj) {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify(obj, null, 2),
|
|
'utf-8'
|
|
);
|
|
}
|
|
|
|
// ─── MODEL_PROFILES data integrity ────────────────────────────────────────────
|
|
|
|
describe('MODEL_PROFILES', () => {
|
|
test('contains every shipped gsd agent file on disk (#3229)', () => {
|
|
// Canonical source roster (sorted gsd-* basenames without .md) — shared helper.
|
|
const expectedAgents = listAgentFiles();
|
|
const actualAgents = Object.keys(MODEL_PROFILES).sort();
|
|
assert.deepStrictEqual(actualAgents, expectedAgents);
|
|
});
|
|
|
|
test('every agent has quality, balanced, budget, and adaptive profiles', () => {
|
|
for (const [agent, profiles] of Object.entries(MODEL_PROFILES)) {
|
|
assert.ok(profiles.quality, `${agent} missing quality profile`);
|
|
assert.ok(profiles.balanced, `${agent} missing balanced profile`);
|
|
assert.ok(profiles.budget, `${agent} missing budget profile`);
|
|
assert.ok(profiles.adaptive, `${agent} missing adaptive profile`);
|
|
}
|
|
});
|
|
|
|
test('all profile values are valid model aliases', () => {
|
|
const validModels = ['opus', 'sonnet', 'haiku'];
|
|
for (const [agent, profiles] of Object.entries(MODEL_PROFILES)) {
|
|
for (const [profile, model] of Object.entries(profiles)) {
|
|
assert.ok(
|
|
validModels.includes(model),
|
|
`${agent}.${profile} has invalid model "${model}" — expected one of ${validModels.join(', ')}`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('quality profile never uses haiku', () => {
|
|
for (const [agent, profiles] of Object.entries(MODEL_PROFILES)) {
|
|
assert.notStrictEqual(
|
|
profiles.quality, 'haiku',
|
|
`${agent} quality profile should not use haiku`
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── VALID_PROFILES ───────────────────────────────────────────────────────────
|
|
|
|
describe('VALID_PROFILES', () => {
|
|
test('contains quality, balanced, budget, adaptive, and inherit', () => {
|
|
assert.deepStrictEqual(VALID_PROFILES.sort(), ['adaptive', 'balanced', 'budget', 'inherit', 'quality']);
|
|
});
|
|
|
|
test('includes all MODEL_PROFILES keys plus inherit', () => {
|
|
const fromData = Object.keys(MODEL_PROFILES['gsd-planner']);
|
|
for (const profile of fromData) {
|
|
assert.ok(VALID_PROFILES.includes(profile), `VALID_PROFILES should include ${profile}`);
|
|
}
|
|
assert.ok(VALID_PROFILES.includes('inherit'), 'VALID_PROFILES should include inherit');
|
|
});
|
|
});
|
|
|
|
// ─── getAgentToModelMapForProfile ─────────────────────────────────────────────
|
|
|
|
describe('getAgentToModelMapForProfile', () => {
|
|
test('returns correct models for balanced profile', () => {
|
|
const map = getAgentToModelMapForProfile('balanced');
|
|
assert.strictEqual(map['gsd-planner'], 'opus');
|
|
assert.strictEqual(map['gsd-codebase-mapper'], 'haiku');
|
|
assert.strictEqual(map['gsd-verifier'], 'sonnet');
|
|
});
|
|
|
|
test('returns correct models for budget profile', () => {
|
|
const map = getAgentToModelMapForProfile('budget');
|
|
assert.strictEqual(map['gsd-planner'], 'sonnet');
|
|
assert.strictEqual(map['gsd-phase-researcher'], 'haiku');
|
|
});
|
|
|
|
test('returns correct models for quality profile', () => {
|
|
const map = getAgentToModelMapForProfile('quality');
|
|
assert.strictEqual(map['gsd-planner'], 'opus');
|
|
assert.strictEqual(map['gsd-executor'], 'opus');
|
|
});
|
|
|
|
test('returns correct models for adaptive profile', () => {
|
|
const map = getAgentToModelMapForProfile('adaptive');
|
|
assert.strictEqual(map['gsd-planner'], 'opus', 'planner should use opus in adaptive');
|
|
assert.strictEqual(map['gsd-debugger'], 'opus', 'debugger should use opus in adaptive');
|
|
assert.strictEqual(map['gsd-executor'], 'sonnet', 'executor should use sonnet in adaptive');
|
|
assert.strictEqual(map['gsd-codebase-mapper'], 'haiku', 'mapper should use haiku in adaptive');
|
|
assert.strictEqual(map['gsd-plan-checker'], 'haiku', 'checker should use haiku in adaptive');
|
|
});
|
|
|
|
// ─── resolution order: override > profile > default ─────────────────────────
|
|
// Uses gsd-phase-researcher because it has visibly distinct values at every
|
|
// level: balanced (default) = sonnet, budget (profile) = haiku, override = opus.
|
|
// Each tier must beat the one below it; the test goes RED if resolveModelInternal
|
|
// ignores model_overrides (returns 'haiku') or conflates default with profile
|
|
// (returns 'sonnet' instead of 'haiku' for budget).
|
|
describe('resolution order: override > profile > default', () => {
|
|
// agent under test — must have three distinct model values across tiers
|
|
const AGENT = 'gsd-phase-researcher';
|
|
const EXPECTED_DEFAULT = 'sonnet'; // balanced profile (no config)
|
|
const EXPECTED_PROFILE = 'haiku'; // budget profile
|
|
const EXPECTED_OVERRIDE = 'opus'; // explicit model_overrides entry
|
|
|
|
let tmpDir;
|
|
beforeEach(() => { tmpDir = createTempProject(); });
|
|
afterEach(() => { cleanup(tmpDir); tmpDir = null; });
|
|
|
|
test('default (no config) resolves to balanced profile model', () => {
|
|
// Sanity-check: balanced is the profile tier when no config is present.
|
|
assert.strictEqual(
|
|
resolveModelInternal(tmpDir, AGENT),
|
|
EXPECTED_DEFAULT,
|
|
`expected balanced-profile default "${EXPECTED_DEFAULT}" but got a different model`
|
|
);
|
|
});
|
|
|
|
test('profile setting (budget) beats the balanced default', () => {
|
|
writeConfig(tmpDir, { model_profile: 'budget' });
|
|
assert.strictEqual(
|
|
resolveModelInternal(tmpDir, AGENT),
|
|
EXPECTED_PROFILE,
|
|
`expected budget-profile model "${EXPECTED_PROFILE}" but got a different model`
|
|
);
|
|
});
|
|
|
|
test('model_overrides entry beats the active profile', () => {
|
|
// budget profile would give haiku; override must win with opus
|
|
writeConfig(tmpDir, {
|
|
model_profile: 'budget',
|
|
model_overrides: { [AGENT]: EXPECTED_OVERRIDE },
|
|
});
|
|
assert.strictEqual(
|
|
resolveModelInternal(tmpDir, AGENT),
|
|
EXPECTED_OVERRIDE,
|
|
`expected override "${EXPECTED_OVERRIDE}" to beat budget-profile model "${EXPECTED_PROFILE}"`
|
|
);
|
|
});
|
|
|
|
test('model_overrides beats the default profile too (no explicit profile key)', () => {
|
|
// Even without an explicit model_profile, override still wins over default
|
|
writeConfig(tmpDir, {
|
|
model_overrides: { [AGENT]: EXPECTED_OVERRIDE },
|
|
});
|
|
assert.strictEqual(
|
|
resolveModelInternal(tmpDir, AGENT),
|
|
EXPECTED_OVERRIDE,
|
|
`expected override "${EXPECTED_OVERRIDE}" to beat balanced default "${EXPECTED_DEFAULT}"`
|
|
);
|
|
});
|
|
});
|
|
|
|
test('returns all agents in the map', () => {
|
|
const map = getAgentToModelMapForProfile('balanced');
|
|
const agentCount = Object.keys(MODEL_PROFILES).length;
|
|
assert.strictEqual(Object.keys(map).length, agentCount);
|
|
});
|
|
});
|
|
|
|
// ─── formatAgentToModelMapAsTable ─────────────────────────────────────────────
|
|
|
|
describe('formatAgentToModelMapAsTable', () => {
|
|
test('produces a table with header and separator', () => {
|
|
const map = { 'gsd-planner': 'opus', 'gsd-executor': 'sonnet' };
|
|
const table = formatAgentToModelMapAsTable(map);
|
|
assert.ok(table.includes('Agent'), 'should have Agent header');
|
|
assert.ok(table.includes('Model'), 'should have Model header');
|
|
assert.ok(table.includes('─'), 'should have separator line');
|
|
assert.ok(table.includes('gsd-planner'), 'should list agent');
|
|
assert.ok(table.includes('opus'), 'should list model');
|
|
});
|
|
|
|
test('pads columns correctly', () => {
|
|
const map = { 'a': 'opus', 'very-long-agent-name': 'haiku' };
|
|
const table = formatAgentToModelMapAsTable(map);
|
|
const lines = table.split('\n').filter(l => l.trim());
|
|
// Separator line uses ┼, data/header lines use │
|
|
const dataLines = lines.filter(l => l.includes('│'));
|
|
const pipePositions = dataLines.map(l => l.indexOf('│'));
|
|
const unique = [...new Set(pipePositions)];
|
|
assert.strictEqual(unique.length, 1, 'all data lines should align on │');
|
|
});
|
|
|
|
test('handles empty map', () => {
|
|
const table = formatAgentToModelMapAsTable({});
|
|
assert.ok(table.includes('Agent'), 'should still have header');
|
|
});
|
|
});
|