fix(routing): skip dynamic routing for interactive dispatches, always show model changes (#3962)

Dynamic routing silently downgraded models in interactive commands (guided-flow),
overriding the user's /model selection. Now routing only applies in auto-mode where
cost optimization is expected. Model downgrade notifications always fire regardless
of verbose setting, and auto-mode shows routing status upfront on start.
This commit is contained in:
Jeremy 2026-04-10 21:51:18 -05:00
parent 8fcaee6d5a
commit 839cd8d55b
4 changed files with 203 additions and 25 deletions

View file

@ -62,6 +62,9 @@ export async function selectAndApplyModel(
verbose: boolean,
autoModeStartModel: { provider: string; id: string } | null,
retryContext?: { isRetry: boolean; previousTier?: string },
/** When false (interactive/guided-flow), skip dynamic routing and use the session model.
* Dynamic routing only applies in auto-mode where cost optimization is expected. (#3962) */
isAutoMode = true,
): Promise<ModelSelectionResult> {
const modelConfig = resolvePreferredModelConfig(unitType, autoModeStartModel);
let routing: { tier: string; modelDowngraded: boolean } | null = null;
@ -71,7 +74,13 @@ export async function selectAndApplyModel(
const availableModels = ctx.modelRegistry.getAvailable();
// ─── Dynamic Model Routing ─────────────────────────────────────────
// Dynamic routing (complexity-based downgrading) only applies in auto-mode.
// Interactive/guided-flow dispatches use the user's session model directly,
// respecting their /model selection without silent downgrades (#3962).
const routingConfig = resolveDynamicRoutingConfig();
if (!isAutoMode) {
routingConfig.enabled = false;
}
let effectiveModelConfig = modelConfig;
let routingTierLabel = "";
@ -123,12 +132,11 @@ export async function selectAndApplyModel(
const escalated = escalateTier(retryContext.previousTier as ComplexityTier);
if (escalated) {
classification = { ...classification, tier: escalated, reason: "escalated after failure" };
if (verbose) {
ctx.ui.notify(
`Tier escalation: ${retryContext.previousTier}${escalated} (retry after failure)`,
"info",
);
}
// Always notify on tier escalation — model changes should be visible (#3962)
ctx.ui.notify(
`Tier escalation: ${retryContext.previousTier}${escalated} (retry after failure)`,
"info",
);
}
}
@ -195,24 +203,23 @@ export async function selectAndApplyModel(
primary: routingResult.modelId,
fallbacks: routingResult.fallbacks,
};
if (verbose) {
if (routingResult.selectionMethod === "capability-scored" && routingResult.capabilityScores) {
// Verbose scoring breakdown for capability-scored decisions (D-20)
const tierLbl = tierLabel(classification.tier);
const scores = Object.entries(routingResult.capabilityScores)
.sort(([, a], [, b]) => b - a)
.map(([id, score]) => `${id}: ${score.toFixed(1)}`)
.join(", ");
ctx.ui.notify(
`Dynamic routing [${tierLbl}]: ${routingResult.modelId} (capability-scored) — ${scores}`,
"info",
);
} else {
ctx.ui.notify(
`Dynamic routing [${tierLabel(classification.tier)}]: ${routingResult.modelId} (${classification.reason})`,
"info",
);
}
// Always notify on model downgrade — users should see when their
// model selection is overridden, not just in verbose mode (#3962).
if (routingResult.selectionMethod === "capability-scored" && routingResult.capabilityScores) {
const tierLbl = tierLabel(classification.tier);
const scores = Object.entries(routingResult.capabilityScores)
.sort(([, a], [, b]) => b - a)
.map(([id, score]) => `${id}: ${score.toFixed(1)}`)
.join(", ");
ctx.ui.notify(
`Dynamic routing [${tierLbl}]: ${routingResult.modelId} (capability-scored) — ${scores}`,
"info",
);
} else {
ctx.ui.notify(
`Dynamic routing [${tierLabel(classification.tier)}]: ${routingResult.modelId} (${classification.reason})`,
"info",
);
}
}
routingTierLabel = ` [${tierLabel(classification.tier)}]`;

View file

@ -83,7 +83,7 @@ import { join } from "node:path";
import { sep as pathSep } from "node:path";
import { resolveProjectRootDbPath } from "./bootstrap/dynamic-tools.js";
import { resolveDefaultSessionModel } from "./preferences-models.js";
import { resolveDefaultSessionModel, resolveDynamicRoutingConfig } from "./preferences-models.js";
import type { WorktreeResolver } from "./worktree-resolver.js";
export interface BootstrapDeps {
@ -778,6 +778,24 @@ export async function bootstrapAutoSession(
: "Will loop until milestone complete.";
ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info");
// Show dynamic routing status so users know upfront if models will be
// downgraded for simple tasks (#3962).
const routingConfig = resolveDynamicRoutingConfig();
const startModelLabel = s.autoModeStartModel
? `${s.autoModeStartModel.provider}/${s.autoModeStartModel.id}`
: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "default";
if (routingConfig.enabled) {
ctx.ui.notify(
`Dynamic routing: enabled — simple tasks may use cheaper models (ceiling: ${startModelLabel})`,
"info",
);
} else {
ctx.ui.notify(
`Dynamic routing: disabled — all tasks will use ${startModelLabel}`,
"info",
);
}
updateSessionLock(
lockBase(),
"starting",

View file

@ -295,6 +295,7 @@ async function dispatchWorkflow(
const result = await selectAndApplyModel(
ctx, pi, unitType, /* unitId */ "", /* basePath */ process.cwd(),
prefs, /* verbose */ false, /* autoModeStartModel */ null,
/* retryContext */ undefined, /* isAutoMode */ false,
);
if (result.appliedModel) {
debugLog("guided-flow-model-applied", {

View file

@ -0,0 +1,152 @@
// GSD Extension — Interactive Routing Bypass Tests
// Verifies that dynamic routing is skipped for interactive (guided-flow) dispatches
// and that model downgrade notifications always fire (#3962).
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import test, { describe } 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));
// ─── Source-level structural tests ──────────────────────────────────────────
const modelSelectionSrc = readFileSync(
join(__dirname, "..", "auto-model-selection.ts"),
"utf-8",
);
const guidedFlowSrc = readFileSync(
join(__dirname, "..", "guided-flow.ts"),
"utf-8",
);
const autoStartSrc = readFileSync(
join(__dirname, "..", "auto-start.ts"),
"utf-8",
);
describe("interactive routing bypass (#3962)", () => {
test("selectAndApplyModel accepts isAutoMode parameter", () => {
// The function signature should include isAutoMode with a default of true
assert.ok(
modelSelectionSrc.includes("isAutoMode"),
"selectAndApplyModel should have isAutoMode parameter",
);
assert.ok(
modelSelectionSrc.includes("isAutoMode = true"),
"isAutoMode should default to true (auto-mode behavior preserved)",
);
});
test("routing is disabled when isAutoMode is false", () => {
// The code should disable routing when not in auto-mode
assert.ok(
modelSelectionSrc.includes("if (!isAutoMode)"),
"should check isAutoMode flag to disable routing",
);
assert.ok(
modelSelectionSrc.includes("routingConfig.enabled = false"),
"should set routingConfig.enabled = false for interactive mode",
);
});
test("guided-flow passes isAutoMode=false", () => {
// guided-flow.ts should explicitly pass isAutoMode as false
assert.ok(
guidedFlowSrc.includes("/* isAutoMode */ false"),
"guided-flow should pass isAutoMode=false to selectAndApplyModel",
);
});
test("auto/phases.ts does NOT pass isAutoMode=false", () => {
// auto/phases.ts should use the default (true) — it's auto-mode
const phasesSrc = readFileSync(
join(__dirname, "..", "auto", "phases.ts"),
"utf-8",
);
assert.ok(
!phasesSrc.includes("isAutoMode"),
"auto/phases.ts should use default isAutoMode=true (not pass it explicitly)",
);
});
});
describe("model downgrade notifications always visible (#3962)", () => {
test("downgrade notification is not gated by verbose flag", () => {
// The downgrade notification block should NOT be wrapped in `if (verbose)`
// Find the downgrade block and verify it's not behind a verbose check
const downgradeBlock = "if (routingResult.wasDowngraded)";
const downgradeIdx = modelSelectionSrc.indexOf(downgradeBlock);
assert.ok(downgradeIdx > 0, "downgrade block should exist");
// Extract the code between wasDowngraded check and the next routing label assignment
const afterDowngrade = modelSelectionSrc.slice(
downgradeIdx,
modelSelectionSrc.indexOf("routingTierLabel =", downgradeIdx),
);
// The notification calls should NOT be wrapped in `if (verbose)`
assert.ok(
!afterDowngrade.includes("if (verbose)"),
"downgrade notifications should not be gated by verbose flag",
);
// But the notification calls should exist
assert.ok(
afterDowngrade.includes('ctx.ui.notify('),
"downgrade notifications should still fire",
);
});
test("tier escalation notification is not gated by verbose flag", () => {
// Extract the escalation block: from "if (escalated)" to its closing
// and verify the notification is present but `if (verbose)` is not.
const escalatedIdx = modelSelectionSrc.indexOf("if (escalated)");
assert.ok(escalatedIdx > 0, "escalation block should exist");
// Get the block from "if (escalated)" to the next closing brace pattern
const block = modelSelectionSrc.slice(escalatedIdx, escalatedIdx + 400);
assert.ok(
block.includes("Tier escalation:"),
"escalation block should contain the notification",
);
assert.ok(
!block.includes("if (verbose)"),
"escalation block should not gate notification behind verbose flag",
);
});
});
describe("auto-mode start routing banner (#3962)", () => {
test("auto-start shows dynamic routing status on startup", () => {
assert.ok(
autoStartSrc.includes("Dynamic routing:"),
"auto-start should display routing status banner",
);
assert.ok(
autoStartSrc.includes("resolveDynamicRoutingConfig"),
"auto-start should import resolveDynamicRoutingConfig",
);
});
test("banner shows different messages for enabled vs disabled routing", () => {
assert.ok(
autoStartSrc.includes("Dynamic routing: enabled"),
"should show message when routing is enabled",
);
assert.ok(
autoStartSrc.includes("Dynamic routing: disabled"),
"should show message when routing is disabled",
);
});
test("banner shows the ceiling model", () => {
assert.ok(
autoStartSrc.includes("startModelLabel"),
"banner should reference the start/ceiling model",
);
});
});