fix(guided-flow): route dispatchWorkflow through dynamic routing pipeline (#3153)

Closes #2958

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 16:33:23 -04:00 committed by GitHub
parent 46dff43e21
commit 45bd2572ac
2 changed files with 149 additions and 18 deletions

View file

@ -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<void> {
// 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,
});
}
}

View file

@ -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 <jeremy@fluxlabs.net>
*/
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",
);
}
});