fix(task): preserved deferred fallback chains

Install subagent retry fallback chains after deferred model patterns resolve so runtime-only candidates keep their ordered fallbacks.\n\nFixes #4421
This commit is contained in:
roboomp
2026-07-03 09:41:25 +00:00
parent 17163a2b9f
commit 4409e6cfb5
4 changed files with 73 additions and 2 deletions
+49 -1
View File
@@ -33,7 +33,9 @@ import { shouldEnableAppendOnlyContext } from "./config/append-only-context-mode
import { shouldInlineToolDescriptors } from "./config/inline-tool-descriptors-mode";
import { isAuthenticated, kNoAuth, ModelRegistry } from "./config/model-registry";
import {
formatModelSelectorValue,
formatModelString,
formatModelStringWithRouting,
getModelMatchPreferences,
parseModelPattern,
parseModelString,
@@ -396,6 +398,8 @@ export interface CreateAgentSessionOptions {
modelPattern?: string | string[];
/** Authenticated fallback selector for deferred subagent model patterns. */
modelPatternAuthFallback?: string;
/** Role name used to install retry fallbacks after deferred subagent patterns resolve. */
modelPatternFallbackRole?: string;
/** Thinking selector. Default: from settings, else unset */
thinkingLevel?: ConfiguredThinkingLevel;
/** Models available for cycling (Ctrl+P in interactive mode) */
@@ -1968,12 +1972,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
if (!model && deferredModelPatterns.length > 0) {
const availableModels = modelRegistry.getAll();
const matchPreferences = getModelMatchPreferences(settings);
for (const pattern of deferredModelPatterns) {
for (let patternIndex = 0; patternIndex < deferredModelPatterns.length; patternIndex += 1) {
const pattern = deferredModelPatterns[patternIndex];
const primary = parseModelPattern(pattern, availableModels, matchPreferences);
if (!primary.model) continue;
let selectedModel = primary.model;
let selectedThinkingLevel = primary.thinkingLevel;
let selectedExplicitThinkingLevel = primary.explicitThinkingLevel;
let authFallbackUsed = false;
if (options.modelPatternAuthFallback) {
const primaryKey = await modelRegistry.getApiKey(primary.model);
if (primaryKey !== kNoAuth && !isAuthenticated(primaryKey)) {
@@ -1988,10 +1994,52 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
selectedModel = fallback.model;
selectedThinkingLevel = fallback.thinkingLevel;
selectedExplicitThinkingLevel = fallback.explicitThinkingLevel;
authFallbackUsed = true;
}
}
}
}
if (!authFallbackUsed && options.modelPatternFallbackRole) {
const primarySelector = formatModelSelectorValue(
formatModelStringWithRouting(primary.model),
primary.thinkingLevel,
);
const seenSelectors = new Set<string>([primarySelector]);
const fallbackSelectors: string[] = [];
for (const fallbackPattern of deferredModelPatterns.slice(patternIndex + 1)) {
const fallback = parseModelPattern(fallbackPattern, availableModels, matchPreferences);
if (!fallback.model) continue;
const fallbackSelector = formatModelSelectorValue(
formatModelStringWithRouting(fallback.model),
fallback.thinkingLevel,
);
if (seenSelectors.has(fallbackSelector)) continue;
seenSelectors.add(fallbackSelector);
fallbackSelectors.push(fallbackSelector);
}
if (fallbackSelectors.length > 0) {
const modelRoles: Record<string, string> = {};
const existingRoles = settings.getModelRoles();
for (const role in existingRoles) {
const selector = existingRoles[role];
if (selector) {
modelRoles[role] = selector;
}
}
modelRoles[options.modelPatternFallbackRole] = primarySelector;
settings.override("modelRoles", modelRoles);
const fallbackChains: Record<string, string[]> = {
[options.modelPatternFallbackRole]: fallbackSelectors,
};
const existingFallbackChains = settings.get("retry.fallbackChains");
for (const role in existingFallbackChains) {
if (role !== options.modelPatternFallbackRole) {
fallbackChains[role] = existingFallbackChains[role];
}
}
settings.override("retry.fallbackChains", fallbackChains);
}
}
model = selectedModel;
modelFallbackMessage = undefined;
if (selectedExplicitThinkingLevel) {
@@ -2164,6 +2164,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
modelPattern: model || modelOverride === undefined ? undefined : modelPatterns,
modelPatternAuthFallback:
model || modelOverride === undefined ? undefined : options.parentActiveModelPattern,
modelPatternFallbackRole:
model || modelOverride === undefined ? undefined : `${SUBAGENT_RETRY_FALLBACK_ROLE_PREFIX}${id}`,
thinkingLevel: effectiveThinkingLevel,
toolNames,
outputSchema,
@@ -154,11 +154,13 @@ describe("subagent runtime model resolution", () => {
let childModel: Model | undefined;
let childModelPattern: unknown;
let childModelPatternAuthFallback: unknown;
let childModelPatternFallbackRole: unknown;
vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => {
if (!options) throw new Error("Expected createAgentSession options");
childModel = options.model;
childModelPattern = options.modelPattern;
childModelPatternAuthFallback = options.modelPatternAuthFallback;
childModelPatternFallbackRole = options.modelPatternFallbackRole;
return { session: createYieldingSession(), extensionsResult: {}, setToolUIContext: () => {} } as never;
});
@@ -183,5 +185,6 @@ describe("subagent runtime model resolution", () => {
expect(childModel).toBeUndefined();
expect(childModelPattern).toEqual(["openai-codex/gpt-5.5:auto"]);
expect(childModelPatternAuthFallback).toBe("openai-codex/gpt-5.5");
expect(childModelPatternFallbackRole).toBe("subagent:issue-4421");
});
});
@@ -80,7 +80,7 @@ describe("createAgentSession deferred model pattern resolution", () => {
pi.registerProvider("runtime-provider", dynamicOnlyProviderConfig);
};
async function buildSessionOptions(modelPattern: string) {
async function buildSessionOptions(modelPattern: string | string[]) {
// Pass an explicit ModelRegistry so createAgentSession skips its implicit
// ModelRegistry.refreshInBackground() — a network model-discovery pass
// (~250ms/session) that contributes nothing here: the model resolves from
@@ -206,6 +206,24 @@ describe("createAgentSession deferred model pattern resolution", () => {
}
});
test("installs fallback chain for remaining deferred subagent modelPattern candidates", async () => {
const { session } = await createAgentSession({
...(await buildSessionOptions(["runtime-provider/runtime-model", "runtime-provider/runtime-reasoning-model"])),
modelPatternFallbackRole: "subagent:deferred",
});
try {
expect(session.model?.provider).toBe("runtime-provider");
expect(session.model?.id).toBe("runtime-model");
expect(session.settings.getModelRole("subagent:deferred")).toBe("runtime-provider/runtime-model");
expect(session.settings.get("retry.fallbackChains")["subagent:deferred"]).toEqual([
"runtime-provider/runtime-reasoning-model",
]);
} finally {
await session.dispose();
}
});
test("does not apply default role thinking override when modelPattern is explicit", async () => {
const settings = Settings.isolated({ defaultThinkingLevel: "off" });
settings.setModelRole("smol", "runtime-provider/runtime-reasoning-model");