From 905ee092cec5adb5ea7c9c53f40e7011460678a8 Mon Sep 17 00:00:00 2001 From: mastertyko <11311479+mastertyko@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:53:51 +0100 Subject: [PATCH] fix(gsd): enable dynamic routing without models section (#2851) * test(integration): suppress npm pack buffer overflows * fix(gsd): enable dynamic routing without models section --- .../extensions/gsd/auto-model-selection.ts | 22 ++- .../gsd/tests/auto-model-selection.test.ts | 139 ++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 src/resources/extensions/gsd/tests/auto-model-selection.test.ts diff --git a/src/resources/extensions/gsd/auto-model-selection.ts b/src/resources/extensions/gsd/auto-model-selection.ts index 5523854d3..7929f94be 100644 --- a/src/resources/extensions/gsd/auto-model-selection.ts +++ b/src/resources/extensions/gsd/auto-model-selection.ts @@ -18,6 +18,26 @@ export interface ModelSelectionResult { routing: { tier: string; modelDowngraded: boolean } | null; } +export function resolvePreferredModelConfig( + unitType: string, + autoModeStartModel: { provider: string; id: string } | null, +) { + const explicitConfig = resolveModelWithFallbacksForUnit(unitType); + if (explicitConfig) return explicitConfig; + + const routingConfig = resolveDynamicRoutingConfig(); + if (!routingConfig.enabled || !routingConfig.tier_models) return undefined; + + const ceilingModel = routingConfig.tier_models.heavy + ?? (autoModeStartModel ? `${autoModeStartModel.provider}/${autoModeStartModel.id}` : undefined); + if (!ceilingModel) return undefined; + + return { + primary: ceilingModel, + fallbacks: [], + }; +} + /** * Select and apply the appropriate model for a unit dispatch. * Handles: per-unit-type model preferences, dynamic complexity routing, @@ -36,7 +56,7 @@ export async function selectAndApplyModel( autoModeStartModel: { provider: string; id: string } | null, retryContext?: { isRetry: boolean; previousTier?: string }, ): Promise { - const modelConfig = resolveModelWithFallbacksForUnit(unitType); + const modelConfig = resolvePreferredModelConfig(unitType, autoModeStartModel); let routing: { tier: string; modelDowngraded: boolean } | null = null; if (modelConfig) { diff --git a/src/resources/extensions/gsd/tests/auto-model-selection.test.ts b/src/resources/extensions/gsd/tests/auto-model-selection.test.ts new file mode 100644 index 000000000..2bc41fa9e --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-model-selection.test.ts @@ -0,0 +1,139 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { resolvePreferredModelConfig } from "../auto-model-selection.js"; + +function makeTempDir(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +test("resolvePreferredModelConfig synthesizes heavy routing ceiling when models section is absent", () => { + const originalCwd = process.cwd(); + const originalGsdHome = process.env.GSD_HOME; + const tempProject = makeTempDir("gsd-routing-project-"); + const tempGsdHome = makeTempDir("gsd-routing-home-"); + + try { + mkdirSync(join(tempProject, ".gsd"), { recursive: true }); + writeFileSync( + join(tempProject, ".gsd", "PREFERENCES.md"), + [ + "---", + "dynamic_routing:", + " enabled: true", + " tier_models:", + " light: claude-haiku-4-5", + " standard: claude-sonnet-4-6", + " heavy: claude-opus-4-6", + "---", + ].join("\n"), + "utf-8", + ); + process.env.GSD_HOME = tempGsdHome; + process.chdir(tempProject); + + const config = resolvePreferredModelConfig("plan-slice", { + provider: "anthropic", + id: "claude-sonnet-4-6", + }); + + assert.deepEqual(config, { + primary: "claude-opus-4-6", + fallbacks: [], + }); + } finally { + process.chdir(originalCwd); + if (originalGsdHome === undefined) delete process.env.GSD_HOME; + else process.env.GSD_HOME = originalGsdHome; + rmSync(tempProject, { recursive: true, force: true }); + rmSync(tempGsdHome, { recursive: true, force: true }); + } +}); + +test("resolvePreferredModelConfig falls back to auto start model when heavy tier is absent", () => { + const originalCwd = process.cwd(); + const originalGsdHome = process.env.GSD_HOME; + const tempProject = makeTempDir("gsd-routing-project-"); + const tempGsdHome = makeTempDir("gsd-routing-home-"); + + try { + mkdirSync(join(tempProject, ".gsd"), { recursive: true }); + writeFileSync( + join(tempProject, ".gsd", "PREFERENCES.md"), + [ + "---", + "dynamic_routing:", + " enabled: true", + " tier_models:", + " light: claude-haiku-4-5", + " standard: claude-sonnet-4-6", + "---", + ].join("\n"), + "utf-8", + ); + process.env.GSD_HOME = tempGsdHome; + process.chdir(tempProject); + + const config = resolvePreferredModelConfig("execute-task", { + provider: "openai", + id: "gpt-5.4", + }); + + assert.deepEqual(config, { + primary: "openai/gpt-5.4", + fallbacks: [], + }); + } finally { + process.chdir(originalCwd); + if (originalGsdHome === undefined) delete process.env.GSD_HOME; + else process.env.GSD_HOME = originalGsdHome; + rmSync(tempProject, { recursive: true, force: true }); + rmSync(tempGsdHome, { recursive: true, force: true }); + } +}); + +test("resolvePreferredModelConfig keeps explicit phase models as the ceiling", () => { + const originalCwd = process.cwd(); + const originalGsdHome = process.env.GSD_HOME; + const tempProject = makeTempDir("gsd-routing-project-"); + const tempGsdHome = makeTempDir("gsd-routing-home-"); + + try { + mkdirSync(join(tempProject, ".gsd"), { recursive: true }); + writeFileSync( + join(tempProject, ".gsd", "PREFERENCES.md"), + [ + "---", + "models:", + " planning: claude-sonnet-4-6", + "dynamic_routing:", + " enabled: true", + " tier_models:", + " heavy: claude-opus-4-6", + "---", + ].join("\n"), + "utf-8", + ); + process.env.GSD_HOME = tempGsdHome; + process.chdir(tempProject); + + const config = resolvePreferredModelConfig("plan-slice", { + provider: "anthropic", + id: "claude-opus-4-6", + }); + + assert.deepEqual(config, { + primary: "claude-sonnet-4-6", + fallbacks: [], + }); + } finally { + process.chdir(originalCwd); + if (originalGsdHome === undefined) delete process.env.GSD_HOME; + else process.env.GSD_HOME = originalGsdHome; + rmSync(tempProject, { recursive: true, force: true }); + rmSync(tempGsdHome, { recursive: true, force: true }); + } +});