diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 5b56ba378..5b1c1d648 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -38,7 +38,7 @@ import { showConfirm } from "../shared/tui.js"; import { debugLog } from "./debug-logger.js"; import { findMilestoneIds, nextMilestoneId, reserveMilestoneId, getReservedMilestoneIds, clearReservedMilestoneIds } from "./milestone-ids.js"; import { parkMilestone, discardMilestone } from "./milestone-actions.js"; -import { resolveModelWithFallbacksForUnit } from "./preferences-models.js"; +import { selectAndApplyModel } from "./auto-model-selection.js"; // ─── Re-exports (preserve public API for existing importers) ──────────────── export { @@ -224,24 +224,20 @@ async function dispatchWorkflow( ctx?: ExtensionContext, unitType?: string, ): Promise { - // Apply model preference for this unit type (if configured) + // Route through the dynamic routing pipeline (complexity classification, + // tier downgrade, fallback chains) — same path as auto-mode dispatches (#2958). if (ctx && unitType) { - const modelConfig = resolveModelWithFallbacksForUnit(unitType); - if (modelConfig) { - const availableModels = ctx.modelRegistry.getAvailable(); - const modelsToTry = [modelConfig.primary, ...modelConfig.fallbacks]; - - for (const modelId of modelsToTry) { - // Resolve model from available models (same logic as auto-model-selection) - const model = resolveAvailableModel(modelId, availableModels, ctx.model?.provider); - if (!model) continue; - - const ok = await pi.setModel(model, { persist: false }); - if (ok) { - debugLog("guided-flow-model-applied", { unitType, model: `${model.provider}/${model.id}` }); - break; - } - } + const prefs = loadEffectiveGSDPreferences()?.preferences; + const result = await selectAndApplyModel( + ctx, pi, unitType, /* unitId */ "", /* basePath */ process.cwd(), + prefs, /* verbose */ false, /* autoModeStartModel */ null, + ); + if (result.appliedModel) { + debugLog("guided-flow-model-applied", { + unitType, + model: `${result.appliedModel.provider}/${result.appliedModel.id}`, + routing: result.routing, + }); } } diff --git a/src/resources/extensions/gsd/tests/guided-flow-dynamic-routing.test.ts b/src/resources/extensions/gsd/tests/guided-flow-dynamic-routing.test.ts new file mode 100644 index 000000000..d9b135426 --- /dev/null +++ b/src/resources/extensions/gsd/tests/guided-flow-dynamic-routing.test.ts @@ -0,0 +1,135 @@ +/** + * Guided-flow dynamic routing — regression test for #2958. + * + * Verifies that dispatchWorkflow() routes through the dynamic routing pipeline + * (selectAndApplyModel from auto-model-selection.ts) instead of bypassing it + * with a direct call to resolveModelWithFallbacksForUnit. + * + * Copyright (c) 2026 Jeremy McSpadden + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const gsdDir = join(__dirname, ".."); + +function readSrc(file: string): string { + return readFileSync(join(gsdDir, file), "utf-8"); +} + +const guidedFlowSrc = readSrc("guided-flow.ts"); + +// ═══════════════════════════════════════════════════════════════════════════ +// #2958: dispatchWorkflow must route through dynamic routing pipeline +// ═══════════════════════════════════════════════════════════════════════════ + +test("#2958: guided-flow imports selectAndApplyModel from auto-model-selection", () => { + assert.ok( + guidedFlowSrc.includes("selectAndApplyModel"), + "guided-flow.ts must import and use selectAndApplyModel from auto-model-selection.ts", + ); +}); + +test("#2958: dispatchWorkflow does not call resolveModelWithFallbacksForUnit directly", () => { + // Extract the dispatchWorkflow function body + const fnStart = guidedFlowSrc.indexOf("async function dispatchWorkflow("); + assert.ok(fnStart !== -1, "dispatchWorkflow function not found"); + + // Find the function body by tracking brace depth + const openBrace = guidedFlowSrc.indexOf("{", fnStart); + let depth = 1; + let pos = openBrace + 1; + while (depth > 0 && pos < guidedFlowSrc.length) { + if (guidedFlowSrc[pos] === "{") depth++; + else if (guidedFlowSrc[pos] === "}") depth--; + pos++; + } + const fnBody = guidedFlowSrc.slice(openBrace, pos); + + assert.ok( + !fnBody.includes("resolveModelWithFallbacksForUnit"), + "dispatchWorkflow must NOT call resolveModelWithFallbacksForUnit directly — " + + "it must route through selectAndApplyModel for dynamic routing support (#2958)", + ); +}); + +test("#2958: dispatchWorkflow calls selectAndApplyModel for model selection", () => { + // Extract the dispatchWorkflow function body + const fnStart = guidedFlowSrc.indexOf("async function dispatchWorkflow("); + assert.ok(fnStart !== -1, "dispatchWorkflow function not found"); + + const openBrace = guidedFlowSrc.indexOf("{", fnStart); + let depth = 1; + let pos = openBrace + 1; + while (depth > 0 && pos < guidedFlowSrc.length) { + if (guidedFlowSrc[pos] === "{") depth++; + else if (guidedFlowSrc[pos] === "}") depth--; + pos++; + } + const fnBody = guidedFlowSrc.slice(openBrace, pos); + + assert.ok( + fnBody.includes("selectAndApplyModel"), + "dispatchWorkflow must call selectAndApplyModel to route through the dynamic routing pipeline (#2958)", + ); +}); + +test("#2958: dispatchWorkflow does not use resolveAvailableModel inline", () => { + const fnStart = guidedFlowSrc.indexOf("async function dispatchWorkflow("); + assert.ok(fnStart !== -1, "dispatchWorkflow function not found"); + + const openBrace = guidedFlowSrc.indexOf("{", fnStart); + let depth = 1; + let pos = openBrace + 1; + while (depth > 0 && pos < guidedFlowSrc.length) { + if (guidedFlowSrc[pos] === "{") depth++; + else if (guidedFlowSrc[pos] === "}") depth--; + pos++; + } + const fnBody = guidedFlowSrc.slice(openBrace, pos); + + assert.ok( + !fnBody.includes("resolveAvailableModel"), + "dispatchWorkflow must NOT use resolveAvailableModel inline — " + + "model resolution is handled by selectAndApplyModel (#2958)", + ); +}); + +test("#2958: guided-flow does not import resolveModelWithFallbacksForUnit", () => { + // The import should be removed since dispatchWorkflow was the only consumer + // Check if resolveModelWithFallbacksForUnit is still used elsewhere in the file + const fnStart = guidedFlowSrc.indexOf("async function dispatchWorkflow("); + const beforeDispatch = guidedFlowSrc.slice(0, fnStart); + const afterFnEnd = (() => { + const openBrace = guidedFlowSrc.indexOf("{", fnStart); + let depth = 1; + let p = openBrace + 1; + while (depth > 0 && p < guidedFlowSrc.length) { + if (guidedFlowSrc[p] === "{") depth++; + else if (guidedFlowSrc[p] === "}") depth--; + p++; + } + return guidedFlowSrc.slice(p); + })(); + + // If resolveModelWithFallbacksForUnit is not used outside dispatchWorkflow, + // the import should be removed + const usedOutside = beforeDispatch.includes("resolveModelWithFallbacksForUnit(") + || afterFnEnd.includes("resolveModelWithFallbacksForUnit("); + + if (!usedOutside) { + // Verify the import line was cleaned up + const importLines = guidedFlowSrc.split("\n").filter(l => + l.includes("import") && l.includes("resolveModelWithFallbacksForUnit"), + ); + assert.equal( + importLines.length, + 0, + "resolveModelWithFallbacksForUnit import should be removed when no longer used outside dispatchWorkflow", + ); + } +});