Files
gstack/test/model-detect.test.ts
T
garrytanandcapy-ai[bot] a1c38753de feat(loadouts): Opus 5.5 lite loadout with explicit model detection (GSTA-161)
modules/loadouts.json gains the lite loadout (eleven coaching preamble
pieces dropped; upgrade-check and brain-sync kept) and a models map that
sends only claude-opus-5-5 (and its dated snapshots) to it. Detection is
explicit: Capy passes --model to the installer, Claude Code opts in with
./setup --loadout lite; anything unmapped, invalid or unreadable serves full.
The checkout always stays the full render.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-31 13:23:23 +00:00

89 lines
6.2 KiB
TypeScript

/**
* Loadout detection (GSTA-161, PR 1b): lib/model-detect.ts resolves a loadout
* from explicit inputs only, and gstack-skill-start prints a lite render's
* LOADOUT lines without executing or echoing a hostile model id.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { resolveLoadout } from '../lib/model-detect';
import { ModuleError, loadLoadouts } from '../scripts/modules';
const ROOT = path.resolve(import.meta.dir, '..');
const manifest = loadLoadouts();
const resolve = (input: { loadout?: string; model?: string; env?: Record<string, string> }) => resolveLoadout({ env: {}, ...input, manifest });
describe('resolveLoadout', () => {
test('only Claude Opus 5.5 (bare, provider-prefixed, dated snapshot or -latest) maps to lite, bound to the base id', () => {
for (const model of ['claude-opus-5-5', 'anthropic/claude-opus-5-5', ' Claude-Opus-5-5[1m] ', 'claude-opus-5-5-20261101', 'claude-opus-5-5-latest']) {
expect(resolve({ model })).toEqual({ loadout: 'lite', source: 'model-flag', model: 'claude-opus-5-5' });
}
});
test('every other model, including near misses of the Opus 5.5 id, resolves to full and never errors', () => {
const near = ['claude-opus-5-6', 'claude-opus-5', 'claude-opus-55x', 'claude-opus-5-50', 'claude-opus-5-5x', 'claude-opus-5-5-1', 'claude-opus-5-5-2026110'];
const others = ['claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-fable-5-1', 'gpt-6-astra', 'some-new-model', 'constructor', 'opus'];
for (const model of [...near, ...others]) expect(resolve({ model })).toEqual({ loadout: 'full', source: 'model-flag', model: null, reason: 'unmapped' });
});
test('an id that is not a plain model id resolves to full with a sanitized notice', () => {
for (const model of ['a$(touch /tmp/x)', "o'pus", 'claude\nopus', 'x'.repeat(200), 'us.anthropic.claude-opus-5-5:0', 'claude-opus-5-5@2026']) {
const r = resolve({ model });
expect(r).toMatchObject({ loadout: 'full', model: null, reason: 'invalid' });
expect(r.notice).toMatch(/^LOADOUT_INVALID_MODEL: ".{0,40}" is not a plain model id; using full/);
expect(r.notice).not.toContain('\n');
}
});
test('order: an explicit loadout (bound to no model), then --model, then GSTACK_MODEL, then full', () => {
expect(resolve({ loadout: 'lite', model: 'claude-sonnet-5-5' })).toEqual({ loadout: 'lite', source: 'loadout-flag', model: null });
expect(resolve({ loadout: 'full', model: 'claude-opus-5-5' })).toEqual({ loadout: 'full', source: 'loadout-flag', model: null });
expect(resolve({ model: 'claude-sonnet-5-5', env: { GSTACK_MODEL: 'claude-opus-5-5' } })).toMatchObject({ loadout: 'full', source: 'model-flag' });
expect(resolve({ env: { GSTACK_MODEL: 'claude-opus-5-5' } })).toEqual({ loadout: 'lite', source: 'GSTACK_MODEL', model: 'claude-opus-5-5' });
expect(resolve({ env: { GSTACK_LOADOUT: 'lite', GSTACK_MODEL: ' ' } })).toEqual({ loadout: 'full', source: 'default', model: null });
expect(resolve({})).toEqual({ loadout: 'full', source: 'default', model: null });
});
test('an unknown explicit loadout is refused; a manifest without a models map serves full', () => {
expect(() => resolve({ loadout: 'nope' })).toThrow(ModuleError);
expect(resolveLoadout({ model: 'claude-opus-5-5', env: {}, manifest: { ...manifest, models: undefined } })).toMatchObject({ loadout: 'full', reason: 'unmapped' });
});
});
describe('gstack-skill-start LOADOUT lines', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'loadout-start-'));
afterAll(() => fs.rmSync(home, { recursive: true, force: true }));
const start = (args: string[], env: Record<string, string> = {}) => {
const r = spawnSync('bash', [path.join(ROOT, 'bin/gstack-skill-start'), '--skill', 'qa', '--model', 'claude', ...args], {
cwd: home, encoding: 'utf-8', timeout: 60_000, env: { PATH: process.env.PATH ?? '', HOME: home, GSTACK_HOME: path.join(home, '.gstack'), ...env },
});
return r.stdout.split('\n').filter(l => l.startsWith('LOADOUT'));
};
const bound = ['--loadout', 'lite', '--loadout-model', 'claude-opus-5-5', '--full-root', '/opt/skills'];
const rule = 'LOADOUT_RULE: this file was rendered for claude-opus-5-5. If you are not running claude-opus-5-5, read /opt/skills/qa/SKILL.md now and follow it instead of this file.';
test('a model-bound render names its model and full root and says when to read the full file', () => {
expect(start(bound)).toEqual(['LOADOUT: lite model=claude-opus-5-5 full_root=/opt/skills', rule]);
for (const running of ['claude-opus-5-5', 'Anthropic/Claude-Opus-5-5 [1m]', 'claude-opus-5-5-20261101']) expect(start(bound, { GSTACK_MODEL: running })).toEqual(['LOADOUT: lite model=claude-opus-5-5 full_root=/opt/skills', rule]);
});
test('a different running model gets LOADOUT_MISMATCH; a hostile GSTACK_MODEL is neither run nor echoed', () => {
expect(start(bound, { GSTACK_MODEL: 'claude-sonnet-5-5' }).at(-1)).toBe('LOADOUT_MISMATCH: claude-opus-5-5 vs claude-sonnet-5-5: read /opt/skills/qa/SKILL.md now and follow it instead of this file.');
expect(start(bound, { GSTACK_MODEL: 'claude-opus-5-50' }).at(-1)).toContain('LOADOUT_MISMATCH: claude-opus-5-5 vs claude-opus-5-50');
const marker = path.join(home, 'pwned');
const lines = start(bound, { GSTACK_MODEL: `$(touch ${marker})` });
expect(lines.at(-1)).toBe('LOADOUT_MISMATCH: claude-opus-5-5 vs an invalid GSTACK_MODEL: read /opt/skills/qa/SKILL.md now and follow it instead of this file.');
expect(fs.existsSync(marker)).toBe(false);
});
test('a malformed bound start line points at the full file; a full or explicit render prints nothing', () => {
const malformed = 'LOADOUT: lite model=unknown: the start line is malformed; read the full SKILL.md for this skill instead of this file.';
expect(start(['--loadout', 'lite'])).toEqual([malformed]);
expect(start(['--loadout', 'lite', '--loadout-model', 'x;id', '--full-root', '/opt/skills'])).toEqual([malformed]);
expect(start(['--loadout', 'lite', '--loadout-model', 'claude-opus-5-5', '--full-root', 'relative'])).toEqual([malformed]);
expect(start([])).toEqual([]);
});
});