582 lines
19 KiB
TypeScript
582 lines
19 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
resolveAgentProviderModel,
|
|
resolveMovementProviderModel,
|
|
resolveProviderModelCandidates,
|
|
} from '../core/piece/provider-resolution.js';
|
|
|
|
describe('resolveProviderModelCandidates', () => {
|
|
it('should resolve first defined provider and model independently', () => {
|
|
const result = resolveProviderModelCandidates([
|
|
{ provider: undefined, model: 'model-1' },
|
|
{ provider: 'codex', model: undefined },
|
|
{ provider: 'claude', model: 'model-2' },
|
|
]);
|
|
|
|
expect(result.provider).toBe('codex');
|
|
expect(result.model).toBe('model-1');
|
|
});
|
|
|
|
it('should return undefined fields when all candidates are undefined', () => {
|
|
const result = resolveProviderModelCandidates([
|
|
{},
|
|
{ provider: undefined, model: undefined },
|
|
]);
|
|
|
|
expect(result.provider).toBeUndefined();
|
|
expect(result.model).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('resolveMovementProviderModel', () => {
|
|
it('should prefer personaProviders.provider over step.provider when both are defined', () => {
|
|
const result = resolveMovementProviderModel({
|
|
step: { provider: 'codex', model: undefined, personaDisplayName: 'coder' },
|
|
provider: 'claude',
|
|
personaProviders: { coder: { provider: 'opencode' } },
|
|
});
|
|
|
|
expect(result.provider).toBe('opencode');
|
|
});
|
|
|
|
it('should use personaProviders.provider when step.provider is undefined', () => {
|
|
const result = resolveMovementProviderModel({
|
|
step: { provider: undefined, model: undefined, personaDisplayName: 'reviewer' },
|
|
provider: 'claude',
|
|
personaProviders: { reviewer: { provider: 'opencode' } },
|
|
});
|
|
|
|
expect(result.provider).toBe('opencode');
|
|
});
|
|
|
|
it('should fallback to input.provider when persona mapping is missing', () => {
|
|
const result = resolveMovementProviderModel({
|
|
step: { provider: undefined, model: undefined, personaDisplayName: 'unknown' },
|
|
provider: 'mock',
|
|
personaProviders: { reviewer: { provider: 'codex' } },
|
|
});
|
|
|
|
expect(result.provider).toBe('mock');
|
|
});
|
|
|
|
it('should return undefined provider when all provider candidates are missing', () => {
|
|
const result = resolveMovementProviderModel({
|
|
step: { provider: undefined, model: undefined, personaDisplayName: 'none' },
|
|
provider: undefined,
|
|
personaProviders: undefined,
|
|
});
|
|
|
|
expect(result.provider).toBeUndefined();
|
|
});
|
|
|
|
it('should prefer personaProviders.model over step.model and input.model', () => {
|
|
const result = resolveMovementProviderModel({
|
|
step: { provider: undefined, model: 'step-model', personaDisplayName: 'coder' },
|
|
model: 'input-model',
|
|
personaProviders: { coder: { provider: 'codex', model: 'persona-model' } },
|
|
});
|
|
|
|
expect(result.model).toBe('persona-model');
|
|
});
|
|
|
|
it('should use personaProviders.model when step.model is undefined', () => {
|
|
const result = resolveMovementProviderModel({
|
|
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
|
|
model: 'input-model',
|
|
personaProviders: { coder: { provider: 'codex', model: 'persona-model' } },
|
|
});
|
|
|
|
expect(result.model).toBe('persona-model');
|
|
});
|
|
|
|
it('should fallback to input.model when step.model and personaProviders.model are undefined', () => {
|
|
const result = resolveMovementProviderModel({
|
|
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
|
|
model: 'input-model',
|
|
personaProviders: { coder: { provider: 'codex' } },
|
|
});
|
|
|
|
expect(result.model).toBe('input-model');
|
|
});
|
|
|
|
it('should return undefined model when all model candidates are missing', () => {
|
|
const result = resolveMovementProviderModel({
|
|
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
|
|
model: undefined,
|
|
personaProviders: { coder: { provider: 'codex' } },
|
|
});
|
|
|
|
expect(result.model).toBeUndefined();
|
|
});
|
|
|
|
it('should resolve provider from personaProviders entry with only model specified', () => {
|
|
const result = resolveMovementProviderModel({
|
|
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
|
|
provider: 'claude',
|
|
personaProviders: { coder: { model: 'o3-mini' } },
|
|
});
|
|
|
|
expect(result.provider).toBe('claude');
|
|
expect(result.model).toBe('o3-mini');
|
|
});
|
|
|
|
it('should resolve cursor provider from personaProviders', () => {
|
|
const result = resolveMovementProviderModel({
|
|
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
|
|
provider: 'claude',
|
|
personaProviders: { coder: { provider: 'cursor' } },
|
|
});
|
|
|
|
expect(result.provider).toBe('cursor');
|
|
});
|
|
});
|
|
|
|
describe('resolveAgentProviderModel', () => {
|
|
it.each([
|
|
{
|
|
name: 'CLI overrides every other layer and also overrides model',
|
|
input: {
|
|
cliProvider: 'codex' as const,
|
|
cliModel: 'cli-model',
|
|
personaProviders: {
|
|
coder: { provider: 'mock' as const, model: 'persona-model' },
|
|
},
|
|
personaDisplayName: 'coder',
|
|
stepProvider: 'claude' as const,
|
|
stepModel: 'step-model',
|
|
localProvider: 'opencode' as const,
|
|
localModel: 'local-model',
|
|
globalProvider: 'mock' as const,
|
|
globalModel: 'global-model',
|
|
},
|
|
expected: { provider: 'codex' as const, model: 'cli-model' },
|
|
},
|
|
{
|
|
name: 'Step overrides local/global when persona is missing',
|
|
input: {
|
|
stepProvider: 'claude' as const,
|
|
stepModel: 'step-model',
|
|
localProvider: 'opencode' as const,
|
|
localModel: 'local-model',
|
|
globalProvider: 'mock' as const,
|
|
globalModel: 'global-model',
|
|
},
|
|
expected: { provider: 'claude' as const, model: 'step-model' },
|
|
},
|
|
{
|
|
name: 'Persona provider wins when CLI is absent',
|
|
input: {
|
|
stepProvider: 'claude' as const,
|
|
personaProviders: {
|
|
coder: { provider: 'mock' as const },
|
|
},
|
|
personaDisplayName: 'coder',
|
|
localProvider: 'opencode' as const,
|
|
localModel: 'local-model',
|
|
globalProvider: 'mock' as const,
|
|
globalModel: 'global-model',
|
|
},
|
|
expected: { provider: 'mock' as const, model: 'global-model' },
|
|
},
|
|
{
|
|
name: 'Persona model wins when no step model and no CLI model',
|
|
input: {
|
|
stepProvider: 'claude' as const,
|
|
stepModel: undefined,
|
|
personaProviders: {
|
|
coder: { model: 'persona-only-model' },
|
|
},
|
|
personaDisplayName: 'coder',
|
|
localProvider: 'opencode' as const,
|
|
localModel: 'local-model',
|
|
globalProvider: 'claude' as const,
|
|
globalModel: 'global-model',
|
|
},
|
|
expected: { provider: 'claude' as const, model: 'persona-only-model' },
|
|
},
|
|
{
|
|
name: 'Step provider wins over local/global provider and step model wins over model-only candidates',
|
|
input: {
|
|
stepProvider: 'codex' as const,
|
|
stepModel: 'step-model',
|
|
personaProviders: {
|
|
coder: { model: 'persona-model' },
|
|
},
|
|
personaDisplayName: 'coder',
|
|
localProvider: 'claude' as const,
|
|
localModel: 'local-model',
|
|
globalProvider: 'mock' as const,
|
|
globalModel: 'global-model',
|
|
},
|
|
expected: { provider: 'codex' as const, model: 'persona-model' },
|
|
},
|
|
{
|
|
name: 'Local provider is used when no higher-priority provider exists',
|
|
input: {
|
|
localProvider: 'opencode' as const,
|
|
localModel: 'local-model',
|
|
globalProvider: 'mock' as const,
|
|
globalModel: 'global-model',
|
|
},
|
|
expected: { provider: 'opencode' as const, model: 'local-model' },
|
|
},
|
|
{
|
|
name: 'Global is used when local provider is absent',
|
|
input: {
|
|
globalProvider: 'mock' as const,
|
|
globalModel: 'global-model',
|
|
},
|
|
expected: { provider: 'mock' as const, model: 'global-model' },
|
|
},
|
|
{
|
|
name: 'No CLI provider or higher layer, CLI model still has model-layer priority',
|
|
input: {
|
|
cliModel: 'cli-model',
|
|
stepModel: 'step-model',
|
|
localProvider: undefined,
|
|
localModel: 'local-model',
|
|
globalProvider: 'mock' as const,
|
|
globalModel: 'global-model',
|
|
},
|
|
expected: { provider: 'mock' as const, model: 'cli-model' },
|
|
},
|
|
{
|
|
name: 'All providers absent, earliest defined model in model order is used',
|
|
input: {
|
|
stepModel: 'step-model',
|
|
localProvider: undefined,
|
|
localModel: 'local-model',
|
|
globalProvider: 'mock' as const,
|
|
globalModel: 'global-model',
|
|
},
|
|
expected: { provider: 'mock' as const, model: 'step-model' },
|
|
},
|
|
{
|
|
name: 'Local model is ignored when it does not match resolved provider',
|
|
input: {
|
|
stepProvider: 'opencode' as const,
|
|
localProvider: 'codex' as const,
|
|
localModel: 'local-model',
|
|
globalProvider: 'mock' as const,
|
|
globalModel: 'global-model',
|
|
},
|
|
expected: { provider: 'opencode' as const, model: undefined },
|
|
},
|
|
{
|
|
name: 'Global model is used when it matches resolved provider',
|
|
input: {
|
|
stepProvider: 'claude' as const,
|
|
localProvider: 'opencode' as const,
|
|
localModel: 'local-model',
|
|
globalProvider: 'claude' as const,
|
|
globalModel: 'global-model',
|
|
},
|
|
expected: { provider: 'claude' as const, model: 'global-model' },
|
|
},
|
|
{
|
|
name: 'Local model is preferred when both local and global providers match',
|
|
input: {
|
|
localProvider: 'mock' as const,
|
|
localModel: 'local-model',
|
|
globalProvider: 'mock' as const,
|
|
globalModel: 'global-model',
|
|
},
|
|
expected: { provider: 'mock' as const, model: 'local-model' },
|
|
},
|
|
{
|
|
name: 'Global model is used when local exists but does not match resolved provider',
|
|
input: {
|
|
stepProvider: 'codex' as const,
|
|
localProvider: 'opencode' as const,
|
|
localModel: 'local-model',
|
|
globalProvider: 'codex' as const,
|
|
globalModel: 'global-model',
|
|
},
|
|
expected: { provider: 'codex' as const, model: 'global-model' },
|
|
},
|
|
{
|
|
name: 'CLI model is used even when provider comes from local and no CLI provider',
|
|
input: {
|
|
cliModel: 'cli-model',
|
|
localProvider: 'mock' as const,
|
|
localModel: 'local-model',
|
|
globalProvider: 'mock' as const,
|
|
globalModel: 'global-model',
|
|
},
|
|
expected: { provider: 'mock' as const, model: 'cli-model' },
|
|
},
|
|
{
|
|
name: 'Persona provider resolves provider, persona model still takes model priority',
|
|
input: {
|
|
stepProvider: 'codex' as const,
|
|
stepModel: 'step-model',
|
|
personaProviders: {
|
|
coder: {
|
|
provider: 'mock' as const,
|
|
model: 'persona-model',
|
|
},
|
|
},
|
|
personaDisplayName: 'coder',
|
|
localProvider: 'claude' as const,
|
|
localModel: 'local-model',
|
|
globalProvider: 'opencode' as const,
|
|
globalModel: 'global-model',
|
|
},
|
|
expected: { provider: 'mock' as const, model: 'persona-model' },
|
|
},
|
|
{
|
|
name: 'Unknown persona name falls back to normal chain without persona model/provider',
|
|
input: {
|
|
stepProvider: 'claude' as const,
|
|
stepModel: 'step-model',
|
|
personaProviders: {
|
|
reviewer: { provider: 'mock' as const, model: 'persona-model' },
|
|
},
|
|
personaDisplayName: 'coder',
|
|
localProvider: 'mock' as const,
|
|
localModel: 'local-model',
|
|
},
|
|
expected: { provider: 'claude' as const, model: 'step-model' },
|
|
},
|
|
{
|
|
name: 'No providers defined and no models defined -> all undefined',
|
|
input: {},
|
|
expected: { provider: undefined, model: undefined },
|
|
},
|
|
{
|
|
name: 'Only CLI model with persona-only model (no provider match), model remains persona-first',
|
|
input: {
|
|
cliModel: 'cli-model',
|
|
personaProviders: {
|
|
coder: { model: 'persona-model' },
|
|
},
|
|
personaDisplayName: 'coder',
|
|
stepProvider: 'mock' as const,
|
|
stepModel: 'step-model',
|
|
},
|
|
expected: { provider: 'mock' as const, model: 'cli-model' },
|
|
},
|
|
])('should resolve %s', ({ input, expected }) => {
|
|
const result = resolveAgentProviderModel(input);
|
|
expect(result).toEqual(expected);
|
|
});
|
|
|
|
it('should resolve provider in order: CLI > persona > movement > local > global', () => {
|
|
const result = resolveAgentProviderModel({
|
|
cliProvider: 'opencode',
|
|
stepProvider: 'claude',
|
|
localProvider: 'codex',
|
|
globalProvider: 'claude',
|
|
personaProviders: { coder: { provider: 'mock' } },
|
|
personaDisplayName: 'coder',
|
|
});
|
|
|
|
expect(result.provider).toBe('opencode');
|
|
});
|
|
|
|
it('should use persona override when no CLI provider is set', () => {
|
|
const result = resolveAgentProviderModel({
|
|
stepProvider: 'claude',
|
|
localProvider: 'codex',
|
|
globalProvider: 'claude',
|
|
personaProviders: { coder: { provider: 'opencode', model: 'persona-model' } },
|
|
personaDisplayName: 'coder',
|
|
});
|
|
|
|
expect(result.provider).toBe('opencode');
|
|
expect(result.model).toBe('persona-model');
|
|
});
|
|
|
|
it('should fall back to movement provider when persona override is not configured', () => {
|
|
const result = resolveAgentProviderModel({
|
|
stepProvider: 'claude',
|
|
localProvider: 'codex',
|
|
globalProvider: 'claude',
|
|
personaProviders: { reviewer: { provider: 'mock', model: 'o3-mini' } },
|
|
personaDisplayName: 'coder',
|
|
});
|
|
|
|
expect(result.provider).toBe('claude');
|
|
});
|
|
|
|
it('should prefer local config provider/model over global config for same provider', () => {
|
|
const result = resolveAgentProviderModel({
|
|
localProvider: 'codex',
|
|
localModel: 'local-model',
|
|
globalProvider: 'codex',
|
|
globalModel: 'global-model',
|
|
});
|
|
|
|
expect(result.provider).toBe('codex');
|
|
expect(result.model).toBe('local-model');
|
|
});
|
|
|
|
it('should prefer global config when local config is not set', () => {
|
|
const result = resolveAgentProviderModel({
|
|
localProvider: undefined,
|
|
globalProvider: 'claude',
|
|
globalModel: 'global-model',
|
|
});
|
|
|
|
expect(result.provider).toBe('claude');
|
|
expect(result.model).toBe('global-model');
|
|
});
|
|
|
|
it('should resolve model order: CLI > persona > movement > config candidate matching provider', () => {
|
|
const result = resolveAgentProviderModel({
|
|
cliModel: 'cli-model',
|
|
stepModel: 'movement-model',
|
|
localProvider: 'claude',
|
|
localModel: 'local-model',
|
|
globalProvider: 'codex',
|
|
globalModel: 'global-model',
|
|
cliProvider: 'codex',
|
|
personaProviders: { coder: { model: 'persona-model' } },
|
|
personaDisplayName: 'coder',
|
|
});
|
|
|
|
expect(result.provider).toBe('codex');
|
|
expect(result.model).toBe('cli-model');
|
|
});
|
|
|
|
it('should use movement model when persona model is absent', () => {
|
|
const result = resolveAgentProviderModel({
|
|
stepModel: 'movement-model',
|
|
localProvider: 'claude',
|
|
localModel: 'local-model',
|
|
globalProvider: 'codex',
|
|
globalModel: 'global-model',
|
|
personaProviders: { coder: { provider: 'opencode' } },
|
|
personaDisplayName: 'coder',
|
|
});
|
|
|
|
expect(result.provider).toBe('opencode');
|
|
expect(result.model).toBe('movement-model');
|
|
});
|
|
|
|
it('should apply local/ global model only when provider matches resolved provider', () => {
|
|
const result = resolveAgentProviderModel({
|
|
localProvider: 'claude',
|
|
localModel: 'local-model',
|
|
globalProvider: 'codex',
|
|
globalModel: 'global-model',
|
|
stepProvider: 'codex',
|
|
});
|
|
|
|
expect(result.provider).toBe('codex');
|
|
expect(result.model).toBe('global-model');
|
|
});
|
|
|
|
it('should ignore local and global model when provider does not match', () => {
|
|
const result = resolveAgentProviderModel({
|
|
localProvider: 'codex',
|
|
localModel: 'local-model',
|
|
globalProvider: 'claude',
|
|
globalModel: 'global-model',
|
|
stepProvider: 'opencode',
|
|
});
|
|
|
|
expect(result.provider).toBe('opencode');
|
|
expect(result.model).toBeUndefined();
|
|
});
|
|
|
|
it('should combine persona and movement overrides in one run', () => {
|
|
const result = resolveAgentProviderModel({
|
|
cliProvider: 'codex',
|
|
stepProvider: 'claude',
|
|
stepModel: 'movement-model',
|
|
localProvider: 'claude',
|
|
localModel: 'local-model',
|
|
globalProvider: 'mock',
|
|
globalModel: 'global-model',
|
|
cliModel: 'cli-model',
|
|
personaProviders: {
|
|
coder: {
|
|
provider: 'mock',
|
|
model: 'persona-model',
|
|
},
|
|
},
|
|
personaDisplayName: 'coder',
|
|
});
|
|
|
|
expect(result.provider).toBe('codex');
|
|
expect(result.model).toBe('cli-model');
|
|
});
|
|
|
|
it('should apply full priority chain when all layers are present', () => {
|
|
const result = resolveAgentProviderModel({
|
|
cliProvider: 'codex',
|
|
cliModel: 'cli-model',
|
|
personaProviders: {
|
|
reviewer: {
|
|
provider: 'mock',
|
|
model: 'persona-model',
|
|
},
|
|
},
|
|
personaDisplayName: 'reviewer',
|
|
stepProvider: 'claude',
|
|
stepModel: 'step-model',
|
|
localProvider: 'opencode',
|
|
localModel: 'local-model',
|
|
globalProvider: 'claude',
|
|
globalModel: 'global-model',
|
|
});
|
|
|
|
expect(result.provider).toBe('codex');
|
|
expect(result.model).toBe('cli-model');
|
|
});
|
|
|
|
it('should apply full priority chain without cli overrides', () => {
|
|
const result = resolveAgentProviderModel({
|
|
personaProviders: {
|
|
reviewer: {
|
|
provider: 'mock',
|
|
model: 'persona-model',
|
|
},
|
|
},
|
|
personaDisplayName: 'reviewer',
|
|
stepProvider: 'claude',
|
|
stepModel: 'step-model',
|
|
localProvider: 'opencode',
|
|
localModel: 'local-model',
|
|
globalProvider: 'claude',
|
|
globalModel: 'global-model',
|
|
});
|
|
|
|
expect(result.provider).toBe('mock');
|
|
expect(result.model).toBe('persona-model');
|
|
});
|
|
|
|
it('should keep model and provider priorities consistent for fallback path', () => {
|
|
const result = resolveAgentProviderModel({
|
|
stepProvider: 'claude',
|
|
localProvider: 'codex',
|
|
localModel: 'local-model',
|
|
globalProvider: 'claude',
|
|
globalModel: 'global-model',
|
|
});
|
|
|
|
expect(result.provider).toBe('claude');
|
|
expect(result.model).toBe('global-model');
|
|
});
|
|
|
|
it('should keep model fallback after persona-only model when step model is absent', () => {
|
|
const result = resolveAgentProviderModel({
|
|
personaProviders: {
|
|
reviewer: {
|
|
model: 'persona-model',
|
|
},
|
|
},
|
|
personaDisplayName: 'reviewer',
|
|
stepProvider: 'claude',
|
|
localProvider: 'codex',
|
|
localModel: 'local-model',
|
|
globalProvider: 'codex',
|
|
globalModel: 'global-model',
|
|
});
|
|
|
|
expect(result.provider).toBe('claude');
|
|
expect(result.model).toBe('persona-model');
|
|
});
|
|
});
|