|
|
@@ -1,5 +1,6 @@
|
|
|
import { describe, expect, test } from 'bun:test';
|
|
|
import type { ModelEntry } from '../config/schema';
|
|
|
+import { normalizeFallbackChainsForPreset } from './fallback-chains';
|
|
|
|
|
|
/**
|
|
|
* Test the model array resolution logic that runs in the config hook.
|
|
|
@@ -65,3 +66,198 @@ describe('model array resolution', () => {
|
|
|
expect(result).toBeNull();
|
|
|
});
|
|
|
});
|
|
|
+
|
|
|
+/**
|
|
|
+ * Tests for the fallback.chains merging logic that runs in the config hook.
|
|
|
+ * Mirrors the effectiveArrays construction in src/index.ts.
|
|
|
+ */
|
|
|
+describe('fallback.chains merging for foreground agents', () => {
|
|
|
+ /**
|
|
|
+ * Simulates the effectiveArrays construction + resolution from src/index.ts.
|
|
|
+ * Returns the resolved model string or null.
|
|
|
+ */
|
|
|
+ function resolveWithChains(opts: {
|
|
|
+ modelArray?: Array<{ id: string; variant?: string }>;
|
|
|
+ currentModel?: string;
|
|
|
+ chainModels?: string[];
|
|
|
+ preset?: string;
|
|
|
+ fallbackEnabled?: boolean;
|
|
|
+ }): string | null {
|
|
|
+ const {
|
|
|
+ modelArray,
|
|
|
+ currentModel,
|
|
|
+ chainModels,
|
|
|
+ preset,
|
|
|
+ fallbackEnabled = true,
|
|
|
+ } = opts;
|
|
|
+
|
|
|
+ // Build effectiveArrays (mirrors index.ts logic)
|
|
|
+ const effectiveArray: Array<{ id: string; variant?: string }> = modelArray
|
|
|
+ ? [...modelArray]
|
|
|
+ : [];
|
|
|
+
|
|
|
+ if (fallbackEnabled && chainModels && chainModels.length > 0) {
|
|
|
+ const normalized = normalizeFallbackChainsForPreset(
|
|
|
+ {
|
|
|
+ [currentModel ?? 'orchestrator']: chainModels,
|
|
|
+ },
|
|
|
+ preset,
|
|
|
+ );
|
|
|
+ const normalizedModels = Object.values(normalized)[0] ?? [];
|
|
|
+ if (effectiveArray.length === 0 && currentModel) {
|
|
|
+ effectiveArray.push({ id: currentModel });
|
|
|
+ }
|
|
|
+ const seen = new Set(effectiveArray.map((m) => m.id));
|
|
|
+ for (const chainModel of normalizedModels) {
|
|
|
+ if (!seen.has(chainModel)) {
|
|
|
+ seen.add(chainModel);
|
|
|
+ effectiveArray.push({ id: chainModel });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (effectiveArray.length === 0) return null;
|
|
|
+
|
|
|
+ // Resolution: always use first model in effective array
|
|
|
+ return effectiveArray[0].id;
|
|
|
+ }
|
|
|
+
|
|
|
+ test('primary model wins regardless of provider config', () => {
|
|
|
+ const result = resolveWithChains({
|
|
|
+ currentModel: 'anthropic/claude-opus-4-5',
|
|
|
+ chainModels: ['openai/gpt-4o'],
|
|
|
+ });
|
|
|
+ expect(result).toBe('anthropic/claude-opus-4-5');
|
|
|
+ });
|
|
|
+
|
|
|
+ test('chain is ignored when fallback disabled', () => {
|
|
|
+ const result = resolveWithChains({
|
|
|
+ currentModel: 'anthropic/claude-opus-4-5',
|
|
|
+ chainModels: ['openai/gpt-4o'],
|
|
|
+ fallbackEnabled: false,
|
|
|
+ });
|
|
|
+ // chain not applied; no effectiveArray entry → falls through to null (no _modelArray either)
|
|
|
+ expect(result).toBeNull();
|
|
|
+ });
|
|
|
+
|
|
|
+ test('_modelArray entries take precedence and chain appends after', () => {
|
|
|
+ const result = resolveWithChains({
|
|
|
+ modelArray: [
|
|
|
+ { id: 'anthropic/claude-opus-4-5' },
|
|
|
+ { id: 'anthropic/claude-sonnet-4-5' },
|
|
|
+ ],
|
|
|
+ chainModels: ['openai/gpt-4o'],
|
|
|
+ });
|
|
|
+ // First entry in _modelArray wins; chain only used for runtime failover
|
|
|
+ expect(result).toBe('anthropic/claude-opus-4-5');
|
|
|
+ });
|
|
|
+
|
|
|
+ test('duplicate model ids across array and chain are deduplicated', () => {
|
|
|
+ const result = resolveWithChains({
|
|
|
+ modelArray: [
|
|
|
+ { id: 'anthropic/claude-opus-4-5' },
|
|
|
+ { id: 'openai/gpt-4o' },
|
|
|
+ ],
|
|
|
+ chainModels: ['openai/gpt-4o', 'google/gemini-pro'],
|
|
|
+ });
|
|
|
+ expect(result).toBe('anthropic/claude-opus-4-5');
|
|
|
+ });
|
|
|
+
|
|
|
+ test('no currentModel and no _modelArray with chain still resolves', () => {
|
|
|
+ const result = resolveWithChains({
|
|
|
+ chainModels: ['openai/gpt-4o', 'anthropic/claude-sonnet-4-5'],
|
|
|
+ });
|
|
|
+ expect(result).toBe('openai/gpt-4o');
|
|
|
+ });
|
|
|
+
|
|
|
+ test('built-in provider not skipped when other providers are configured', () => {
|
|
|
+ // Regression test: github-copilot is auto-loaded by opencode and doesn't
|
|
|
+ // need an entry in opencodeConfig.provider. The resolver must not skip
|
|
|
+ // it in favor of a configured provider later in the chain.
|
|
|
+ const result = resolveWithChains({
|
|
|
+ currentModel: 'github-copilot/claude-opus-4.6',
|
|
|
+ chainModels: [
|
|
|
+ 'github-copilot/gemini-3.1-pro-preview',
|
|
|
+ 'zai-coding-plan/glm-5',
|
|
|
+ ],
|
|
|
+ });
|
|
|
+ expect(result).toBe('github-copilot/claude-opus-4.6');
|
|
|
+ });
|
|
|
+
|
|
|
+ test('normalizes scoped fallback chains for the active preset', () => {
|
|
|
+ const result = normalizeFallbackChainsForPreset(
|
|
|
+ {
|
|
|
+ 'gpt-plus-max:orchestrator': [
|
|
|
+ 'openai/o3',
|
|
|
+ 'anthropic/claude-sonnet-4-6',
|
|
|
+ ],
|
|
|
+ 'gpt-plus-max:oracle': ['anthropic/claude-opus-4-5'],
|
|
|
+ },
|
|
|
+ 'gpt-plus-max',
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(result).toEqual({
|
|
|
+ orchestrator: ['openai/o3', 'anthropic/claude-sonnet-4-6'],
|
|
|
+ oracle: ['anthropic/claude-opus-4-5'],
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ test('ignores scoped fallback chains for other presets', () => {
|
|
|
+ const result = normalizeFallbackChainsForPreset(
|
|
|
+ {
|
|
|
+ 'gpt-plus-max:orchestrator': ['openai/o3'],
|
|
|
+ orchestrator: ['anthropic/claude-sonnet-4-6'],
|
|
|
+ },
|
|
|
+ 'other-preset',
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(result).toEqual({
|
|
|
+ orchestrator: ['anthropic/claude-sonnet-4-6'],
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ test('does not emit scoped keys in normalized output', () => {
|
|
|
+ const result = normalizeFallbackChainsForPreset(
|
|
|
+ {
|
|
|
+ 'gpt-plus-max:orchestrator': ['openai/o3'],
|
|
|
+ },
|
|
|
+ 'gpt-plus-max',
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(Object.keys(result).some((key) => key.includes(':'))).toBe(false);
|
|
|
+ });
|
|
|
+
|
|
|
+ test('ignores empty fallback chain agent names', () => {
|
|
|
+ const result = normalizeFallbackChainsForPreset(
|
|
|
+ {
|
|
|
+ '': ['openai/o3'],
|
|
|
+ 'gpt-plus-max:': ['anthropic/claude-sonnet-4-6'],
|
|
|
+ orchestrator: ['ustc-deepseek/deepseek-v4-pro'],
|
|
|
+ },
|
|
|
+ 'gpt-plus-max',
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(result).toEqual({
|
|
|
+ orchestrator: ['ustc-deepseek/deepseek-v4-pro'],
|
|
|
+ });
|
|
|
+ expect(result).not.toHaveProperty('');
|
|
|
+ });
|
|
|
+
|
|
|
+ test('runtime preset takes precedence over config preset for scoped fallback chains', () => {
|
|
|
+ const configPreset = 'cheap';
|
|
|
+ const runtimePreset = 'powerful';
|
|
|
+ const activePreset = runtimePreset ?? configPreset ?? null;
|
|
|
+
|
|
|
+ const result = normalizeFallbackChainsForPreset(
|
|
|
+ {
|
|
|
+ 'cheap:orchestrator': ['cheap/model'],
|
|
|
+ 'powerful:orchestrator': ['powerful/model'],
|
|
|
+ },
|
|
|
+ activePreset,
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(result).toEqual({
|
|
|
+ orchestrator: ['powerful/model'],
|
|
|
+ });
|
|
|
+ });
|
|
|
+});
|