diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index add1f947f..6854bcc8e 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -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([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 = {}; + 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 = { + [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) { diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index bfb872b67..92761ac80 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -2164,6 +2164,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise { 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"); }); }); diff --git a/packages/coding-agent/test/sdk-model-selection.test.ts b/packages/coding-agent/test/sdk-model-selection.test.ts index 60d6b80fc..6d87f9f3c 100644 --- a/packages/coding-agent/test/sdk-model-selection.test.ts +++ b/packages/coding-agent/test/sdk-model-selection.test.ts @@ -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");