fix(delegate-task): gate fallback settings to real fallback matches
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -169,6 +169,52 @@ describe("resolveCategoryExecution", () => {
|
||||
agentsSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("does not apply object-style fallback settings when the configured primary model matches directly", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
models: { openai: ["gpt-5.4-preview"] },
|
||||
connected: ["openai"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
const args = {
|
||||
category: "deep",
|
||||
prompt: "test prompt",
|
||||
description: "Test task",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
blockedBy: undefined,
|
||||
enableSkillTools: false,
|
||||
}
|
||||
const executorCtx = createMockExecutorContext()
|
||||
executorCtx.userCategories = {
|
||||
deep: {
|
||||
model: "openai/gpt-5.4-preview",
|
||||
fallback_models: [
|
||||
{
|
||||
model: "openai/gpt-5.4",
|
||||
variant: "low",
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await resolveCategoryExecution(args, executorCtx, undefined, "anthropic/claude-sonnet-4-6")
|
||||
|
||||
//#then
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.actualModel).toBe("openai/gpt-5.4-preview")
|
||||
expect(result.categoryModel).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4-preview",
|
||||
variant: "medium",
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
agentsSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("matches promoted fallback settings after fuzzy model resolution", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
|
||||
@@ -89,6 +89,7 @@ Available categories: ${allCategoryNames}`,
|
||||
let categoryModel: DelegatedModelConfig | undefined
|
||||
let isModelResolutionSkipped = false
|
||||
let fallbackEntry: FallbackEntry | undefined
|
||||
let matchedFallback = false
|
||||
|
||||
const overrideModel = sisyphusJuniorModel
|
||||
const explicitCategoryModel = userCategories?.[args.category!]?.model
|
||||
@@ -122,8 +123,14 @@ Available categories: ${allCategoryNames}`,
|
||||
if (resolution && "skipped" in resolution) {
|
||||
isModelResolutionSkipped = true
|
||||
} else if (resolution) {
|
||||
const { model: resolvedModel, variant: resolvedVariant, fallbackEntry: resolvedFallbackEntry } = resolution
|
||||
const {
|
||||
model: resolvedModel,
|
||||
variant: resolvedVariant,
|
||||
fallbackEntry: resolvedFallbackEntry,
|
||||
matchedFallback: resolvedMatchedFallback,
|
||||
} = resolution
|
||||
fallbackEntry = resolvedFallbackEntry
|
||||
matchedFallback = resolvedMatchedFallback === true
|
||||
actualModel = resolvedModel
|
||||
|
||||
if (!parseModelString(actualModel)) {
|
||||
@@ -202,13 +209,15 @@ Available categories: ${categoryNames.join(", ")}`,
|
||||
defaultProviderID,
|
||||
)
|
||||
|
||||
// Apply per-model settings from the source that provided the match:
|
||||
// 1. fallbackEntry from resolver (built-in chain match) — exact, no lookup needed
|
||||
// 2. configuredFallbackChain (user's fallback_models) — prefix match against user config
|
||||
const effectiveEntry = fallbackEntry
|
||||
?? (categoryModel && configuredFallbackChain
|
||||
? findMostSpecificFallbackEntry(categoryModel.providerID, categoryModel.modelID, configuredFallbackChain)
|
||||
: undefined)
|
||||
// Only promote fallback-only settings when resolution actually selected a fallback model.
|
||||
const effectiveEntry = matchedFallback && categoryModel
|
||||
? (
|
||||
fallbackEntry
|
||||
?? (configuredFallbackChain
|
||||
? findMostSpecificFallbackEntry(categoryModel.providerID, categoryModel.modelID, configuredFallbackChain)
|
||||
: undefined)
|
||||
)
|
||||
: undefined
|
||||
|
||||
if (categoryModel && effectiveEntry) {
|
||||
categoryModel = {
|
||||
|
||||
@@ -121,7 +121,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
availableModels: new Set(["openai/gpt-5.2"]),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: "openai/gpt-5.2", variant: "high" })
|
||||
expect(result).toEqual({ model: "openai/gpt-5.2", variant: "high", matchedFallback: true })
|
||||
})
|
||||
|
||||
test("#then resolves a space-separated variant against the base available model", () => {
|
||||
@@ -130,7 +130,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
availableModels: new Set(["openai/gpt-5.2"]),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: "openai/gpt-5.2", variant: "medium" })
|
||||
expect(result).toEqual({ model: "openai/gpt-5.2", variant: "medium", matchedFallback: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,7 +53,7 @@ export function resolveModelForDelegateTask(input: {
|
||||
fallbackChain?: FallbackEntry[]
|
||||
availableModels: Set<string>
|
||||
systemDefaultModel?: string
|
||||
}): { model: string; variant?: string; fallbackEntry?: FallbackEntry } | { skipped: true } | undefined {
|
||||
}): { model: string; variant?: string; fallbackEntry?: FallbackEntry; matchedFallback?: boolean } | { skipped: true } | undefined {
|
||||
const userModel = normalizeModel(input.userModel)
|
||||
if (userModel) {
|
||||
return { model: userModel }
|
||||
@@ -97,7 +97,7 @@ export function resolveModelForDelegateTask(input: {
|
||||
if (input.availableModels.size === 0) {
|
||||
const first = userFallbackModels[0] ? parseUserFallbackModel(userFallbackModels[0]) : undefined
|
||||
if (first) {
|
||||
return { model: first.baseModel, variant: first.variant }
|
||||
return { model: first.baseModel, variant: first.variant, matchedFallback: true }
|
||||
}
|
||||
} else {
|
||||
for (const fallbackModel of userFallbackModels) {
|
||||
@@ -106,7 +106,7 @@ export function resolveModelForDelegateTask(input: {
|
||||
|
||||
const match = fuzzyMatchModel(parsedFallback.baseModel, input.availableModels, parsedFallback.providerHint)
|
||||
if (match) {
|
||||
return { model: match, variant: parsedFallback.variant }
|
||||
return { model: match, variant: parsedFallback.variant, matchedFallback: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export function resolveModelForDelegateTask(input: {
|
||||
const provider = first?.providers?.[0]
|
||||
if (provider) {
|
||||
const transformedModelId = transformModelForProvider(provider, first.model)
|
||||
return { model: `${provider}/${transformedModelId}`, variant: first.variant, fallbackEntry: first }
|
||||
return { model: `${provider}/${transformedModelId}`, variant: first.variant, fallbackEntry: first, matchedFallback: true }
|
||||
}
|
||||
} else {
|
||||
for (const entry of fallbackChain) {
|
||||
@@ -128,20 +128,20 @@ export function resolveModelForDelegateTask(input: {
|
||||
const match = fuzzyMatchModel(fullModel, input.availableModels, [provider])
|
||||
if (match) {
|
||||
if (explicitHighModel && entry.variant === "high" && match === explicitHighBaseModel) {
|
||||
return { model: explicitHighModel, fallbackEntry: entry }
|
||||
return { model: explicitHighModel, fallbackEntry: entry, matchedFallback: true }
|
||||
}
|
||||
|
||||
return { model: match, variant: entry.variant, fallbackEntry: entry }
|
||||
return { model: match, variant: entry.variant, fallbackEntry: entry, matchedFallback: true }
|
||||
}
|
||||
}
|
||||
|
||||
const crossProviderMatch = fuzzyMatchModel(entry.model, input.availableModels)
|
||||
if (crossProviderMatch) {
|
||||
if (explicitHighModel && entry.variant === "high" && crossProviderMatch === explicitHighBaseModel) {
|
||||
return { model: explicitHighModel, fallbackEntry: entry }
|
||||
return { model: explicitHighModel, fallbackEntry: entry, matchedFallback: true }
|
||||
}
|
||||
|
||||
return { model: crossProviderMatch, variant: entry.variant, fallbackEntry: entry }
|
||||
return { model: crossProviderMatch, variant: entry.variant, fallbackEntry: entry, matchedFallback: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,47 @@ describe("resolveSubagentExecution", () => {
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("does not apply object-style fallback settings when the subagent primary model matches directly", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
models: { openai: ["gpt-5.4-preview"] },
|
||||
connected: ["openai"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
const args = createBaseArgs({ subagent_type: "explore" })
|
||||
const executorCtx = createExecutorContext(
|
||||
async () => ([
|
||||
{ name: "explore", mode: "subagent", model: "openai/gpt-5.4-preview" },
|
||||
]),
|
||||
{
|
||||
agentOverrides: {
|
||||
explore: {
|
||||
fallback_models: [
|
||||
{
|
||||
model: "openai/gpt-5.4",
|
||||
variant: "low",
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
],
|
||||
},
|
||||
} as ExecutorContext["agentOverrides"],
|
||||
}
|
||||
)
|
||||
|
||||
//#when
|
||||
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||
|
||||
//#then
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.categoryModel).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4-preview",
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("matches promoted fallback settings after fuzzy model resolution", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
|
||||
@@ -142,12 +142,17 @@ Create the work plan directly - that's your job as the planning agent.`,
|
||||
)
|
||||
fallbackChain = configuredFallbackChain ?? agentRequirement?.fallbackChain
|
||||
|
||||
// Apply per-model settings: prefer resolver's exact entry, fall back to prefix match on user config
|
||||
// Only promote fallback-only settings when resolution actually selected a fallback model.
|
||||
const resolvedFallbackEntry = (resolution && !('skipped' in resolution)) ? resolution.fallbackEntry : undefined
|
||||
const effectiveEntry = resolvedFallbackEntry
|
||||
?? (categoryModel && fallbackChain
|
||||
? findMostSpecificFallbackEntry(categoryModel.providerID, categoryModel.modelID, fallbackChain)
|
||||
: undefined)
|
||||
const matchedFallback = (resolution && !('skipped' in resolution)) ? resolution.matchedFallback === true : false
|
||||
const effectiveEntry = matchedFallback && categoryModel
|
||||
? (
|
||||
resolvedFallbackEntry
|
||||
?? (configuredFallbackChain
|
||||
? findMostSpecificFallbackEntry(categoryModel.providerID, categoryModel.modelID, configuredFallbackChain)
|
||||
: undefined)
|
||||
)
|
||||
: undefined
|
||||
|
||||
if (categoryModel && effectiveEntry) {
|
||||
categoryModel = {
|
||||
|
||||
@@ -237,7 +237,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
return executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
|
||||
}
|
||||
} else {
|
||||
const resolution = await resolveSubagentExecution(args, options, parentContext.agent, categoryExamples, inheritedModel)
|
||||
const resolution = await resolveSubagentExecution(args, options, parentContext.agent, categoryExamples)
|
||||
if (resolution.error) {
|
||||
return resolution.error
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user