ae0c106ed4
After the 4.2.0 unified-dispatch refactor (a42f894f/df198d8b/fee515c5/989ab717/dd3fecaf/1bbe065c/12bd6580), at least one caller in the new prompt-async-gate path forwards a FallbackModelObject (or some other non-string shape) into parsers that statically claim 'model: string'. The downstream .trim() call then throws 'model.trim is not a function', which rejects the session.processor promise and surfaces as 'Aborted process' + UI 'interrupted'. The issue (#4145) reports this aborts 90% of subagent dispatches across every provider on 4.2.0 + opencode 1.15.4. This patch adds a 'typeof x !== "string"' runtime guard at the four parser entrypoints called from the dispatch path: - src/shared/fallback-chain-from-models.ts :: parseVariantFromModel, parseFallbackModelEntry - src/tools/delegate-task/model-string-parser.ts :: parseVariantFromModelID, parseModelString - src/shared/model-string-parser.ts (duplicate file with same API) :: parseVariantFromModelID, parseModelString - src/features/claude-code-agent-loader/claude-model-mapper.ts :: mapClaudeModelString Each parser now returns undefined / { modelID: "" } for non-string input instead of throwing. This unblocks subagent dispatch and leaves the underlying caller bug for a follow-up. Regression coverage: three new tests in src/shared/fallback-chain-from-models.test.ts pin the non-string behavior (object, null/undefined, number). Existing 38 tests still pass. Total: 41/41 green, typecheck clean.
460 lines
14 KiB
TypeScript
460 lines
14 KiB
TypeScript
import { describe, test, it, expect } from "bun:test"
|
|
import {
|
|
parseFallbackModelEntry,
|
|
parseFallbackModelObjectEntry,
|
|
buildFallbackChainFromModels,
|
|
findMostSpecificFallbackEntry,
|
|
} from "./fallback-chain-from-models"
|
|
import { flattenToFallbackModelStrings } from "./model-resolver"
|
|
|
|
// Upstream tests
|
|
describe("fallback-chain-from-models", () => {
|
|
test("parses provider/model entry with parenthesized variant", () => {
|
|
//#given
|
|
const fallbackModel = "openai/gpt-5.2(high)"
|
|
|
|
//#when
|
|
const parsed = parseFallbackModelEntry(fallbackModel, "quotio")
|
|
|
|
//#then
|
|
expect(parsed).toEqual({
|
|
providers: ["openai"],
|
|
model: "gpt-5.2",
|
|
variant: "high",
|
|
})
|
|
})
|
|
|
|
test("uses default provider when fallback model omits provider prefix", () => {
|
|
//#given
|
|
const fallbackModel = "glm-5"
|
|
|
|
//#when
|
|
const parsed = parseFallbackModelEntry(fallbackModel, "quotio")
|
|
|
|
//#then
|
|
expect(parsed).toEqual({
|
|
providers: ["quotio"],
|
|
model: "glm-5",
|
|
variant: undefined,
|
|
})
|
|
})
|
|
|
|
test("uses opencode as absolute fallback provider when context provider is missing", () => {
|
|
//#given
|
|
const fallbackModel = "gemini-3-flash"
|
|
|
|
//#when
|
|
const parsed = parseFallbackModelEntry(fallbackModel, undefined)
|
|
|
|
//#then
|
|
expect(parsed).toEqual({
|
|
providers: ["opencode"],
|
|
model: "gemini-3-flash",
|
|
variant: undefined,
|
|
})
|
|
})
|
|
|
|
test("builds fallback chain from normalized fallback_models input", () => {
|
|
//#given
|
|
const fallbackModels = ["quotio/kimi-k2.5", "gpt-5.2 medium"]
|
|
|
|
//#when
|
|
const chain = buildFallbackChainFromModels(fallbackModels, "quotio")
|
|
|
|
//#then
|
|
expect(chain).toEqual([
|
|
{ providers: ["quotio"], model: "kimi-k2.5", variant: undefined },
|
|
{ providers: ["quotio"], model: "gpt-5.2", variant: "medium" },
|
|
])
|
|
})
|
|
})
|
|
|
|
// Object-style entry tests
|
|
describe("parseFallbackModelEntry (extended)", () => {
|
|
it("parses provider/model string", () => {
|
|
const result = parseFallbackModelEntry("anthropic/claude-sonnet-4-6", undefined)
|
|
expect(result).toEqual({
|
|
providers: ["anthropic"],
|
|
model: "claude-sonnet-4-6",
|
|
})
|
|
})
|
|
|
|
it("parses model with parenthesized variant", () => {
|
|
const result = parseFallbackModelEntry("anthropic/claude-sonnet-4-6(high)", undefined)
|
|
expect(result).toEqual({
|
|
providers: ["anthropic"],
|
|
model: "claude-sonnet-4-6",
|
|
variant: "high",
|
|
})
|
|
})
|
|
|
|
it("parses model with space variant", () => {
|
|
const result = parseFallbackModelEntry("openai/gpt-5.4 xhigh", undefined)
|
|
expect(result).toEqual({
|
|
providers: ["openai"],
|
|
model: "gpt-5.4",
|
|
variant: "xhigh",
|
|
})
|
|
})
|
|
|
|
it("parses model with minimal space variant", () => {
|
|
const result = parseFallbackModelEntry("openai/gpt-5.4 minimal", undefined)
|
|
expect(result).toEqual({
|
|
providers: ["openai"],
|
|
model: "gpt-5.4",
|
|
variant: "minimal",
|
|
})
|
|
})
|
|
|
|
it("uses context provider when no provider prefix", () => {
|
|
const result = parseFallbackModelEntry("claude-sonnet-4-6", "anthropic")
|
|
expect(result).toEqual({
|
|
providers: ["anthropic"],
|
|
model: "claude-sonnet-4-6",
|
|
})
|
|
})
|
|
|
|
it("returns undefined for empty string", () => {
|
|
expect(parseFallbackModelEntry("", undefined)).toBeUndefined()
|
|
expect(parseFallbackModelEntry(" ", undefined)).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe("parseFallbackModelObjectEntry", () => {
|
|
it("parses object with model only", () => {
|
|
const result = parseFallbackModelObjectEntry(
|
|
{ model: "anthropic/claude-sonnet-4-6" },
|
|
undefined,
|
|
)
|
|
expect(result).toEqual({
|
|
providers: ["anthropic"],
|
|
model: "claude-sonnet-4-6",
|
|
})
|
|
})
|
|
|
|
it("parses object with variant override", () => {
|
|
const result = parseFallbackModelObjectEntry(
|
|
{ model: "anthropic/claude-sonnet-4-6", variant: "high" },
|
|
undefined,
|
|
)
|
|
expect(result).toEqual({
|
|
providers: ["anthropic"],
|
|
model: "claude-sonnet-4-6",
|
|
variant: "high",
|
|
})
|
|
})
|
|
|
|
it("object variant overrides inline variant", () => {
|
|
const result = parseFallbackModelObjectEntry(
|
|
{ model: "anthropic/claude-sonnet-4-6(low)", variant: "high" },
|
|
undefined,
|
|
)
|
|
expect(result).toEqual({
|
|
providers: ["anthropic"],
|
|
model: "claude-sonnet-4-6",
|
|
variant: "high",
|
|
})
|
|
})
|
|
|
|
it("carries reasoningEffort and temperature", () => {
|
|
const result = parseFallbackModelObjectEntry(
|
|
{
|
|
model: "openai/gpt-5.4",
|
|
variant: "high",
|
|
reasoningEffort: "high",
|
|
temperature: 0.5,
|
|
},
|
|
undefined,
|
|
)
|
|
expect(result).toEqual({
|
|
providers: ["openai"],
|
|
model: "gpt-5.4",
|
|
variant: "high",
|
|
reasoningEffort: "high",
|
|
temperature: 0.5,
|
|
})
|
|
})
|
|
|
|
it("carries thinking config", () => {
|
|
const result = parseFallbackModelObjectEntry(
|
|
{
|
|
model: "anthropic/claude-sonnet-4-6",
|
|
thinking: { type: "enabled", budgetTokens: 10000 },
|
|
},
|
|
undefined,
|
|
)
|
|
expect(result).toEqual({
|
|
providers: ["anthropic"],
|
|
model: "claude-sonnet-4-6",
|
|
thinking: { type: "enabled", budgetTokens: 10000 },
|
|
})
|
|
})
|
|
|
|
it("carries all optional fields", () => {
|
|
const result = parseFallbackModelObjectEntry(
|
|
{
|
|
model: "openai/gpt-5.4",
|
|
variant: "xhigh",
|
|
reasoningEffort: "xhigh",
|
|
temperature: 0.3,
|
|
top_p: 0.9,
|
|
maxTokens: 8192,
|
|
thinking: { type: "disabled" },
|
|
},
|
|
undefined,
|
|
)
|
|
expect(result).toEqual({
|
|
providers: ["openai"],
|
|
model: "gpt-5.4",
|
|
variant: "xhigh",
|
|
reasoningEffort: "xhigh",
|
|
temperature: 0.3,
|
|
top_p: 0.9,
|
|
maxTokens: 8192,
|
|
thinking: { type: "disabled" },
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("buildFallbackChainFromModels (mixed)", () => {
|
|
it("handles string input", () => {
|
|
const result = buildFallbackChainFromModels("anthropic/claude-sonnet-4-6", undefined)
|
|
expect(result).toEqual([
|
|
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
|
])
|
|
})
|
|
|
|
it("handles string array", () => {
|
|
const result = buildFallbackChainFromModels(
|
|
["anthropic/claude-sonnet-4-6", "openai/gpt-5.4"],
|
|
undefined,
|
|
)
|
|
expect(result).toEqual([
|
|
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
|
{ providers: ["openai"], model: "gpt-5.4" },
|
|
])
|
|
})
|
|
|
|
it("handles mixed array of strings and objects", () => {
|
|
const result = buildFallbackChainFromModels(
|
|
[
|
|
{ model: "anthropic/claude-sonnet-4-6", variant: "high", reasoningEffort: "high" },
|
|
{ model: "openai/gpt-5.4", reasoningEffort: "xhigh" },
|
|
"chutes/kimi-k2.5",
|
|
{ model: "chutes/glm-5", temperature: 0.7 },
|
|
"google/gemini-3-flash",
|
|
],
|
|
undefined,
|
|
)
|
|
expect(result).toEqual([
|
|
{ providers: ["anthropic"], model: "claude-sonnet-4-6", variant: "high", reasoningEffort: "high" },
|
|
{ providers: ["openai"], model: "gpt-5.4", reasoningEffort: "xhigh" },
|
|
{ providers: ["chutes"], model: "kimi-k2.5" },
|
|
{ providers: ["chutes"], model: "glm-5", temperature: 0.7 },
|
|
{ providers: ["google"], model: "gemini-3-flash" },
|
|
])
|
|
})
|
|
|
|
it("returns undefined for empty/undefined input", () => {
|
|
expect(buildFallbackChainFromModels(undefined, undefined)).toBeUndefined()
|
|
expect(buildFallbackChainFromModels([], undefined)).toBeUndefined()
|
|
})
|
|
|
|
it("filters out invalid entries", () => {
|
|
const result = buildFallbackChainFromModels(
|
|
["", "anthropic/claude-sonnet-4-6", " "],
|
|
undefined,
|
|
)
|
|
expect(result).toEqual([
|
|
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
|
])
|
|
})
|
|
})
|
|
|
|
describe("flattenToFallbackModelStrings", () => {
|
|
it("returns undefined for undefined input", () => {
|
|
expect(flattenToFallbackModelStrings(undefined)).toBeUndefined()
|
|
})
|
|
|
|
it("passes through plain strings", () => {
|
|
expect(flattenToFallbackModelStrings(["anthropic/claude-sonnet-4-6"])).toEqual([
|
|
"anthropic/claude-sonnet-4-6",
|
|
])
|
|
})
|
|
|
|
it("flattens object with explicit variant", () => {
|
|
expect(flattenToFallbackModelStrings([
|
|
{ model: "anthropic/claude-sonnet-4-6", variant: "high" },
|
|
])).toEqual(["anthropic/claude-sonnet-4-6(high)"])
|
|
})
|
|
|
|
it("preserves inline variant when no explicit variant", () => {
|
|
expect(flattenToFallbackModelStrings([
|
|
{ model: "anthropic/claude-sonnet-4-6(high)" },
|
|
])).toEqual(["anthropic/claude-sonnet-4-6(high)"])
|
|
})
|
|
|
|
it("explicit variant overrides inline variant (no double-suffix)", () => {
|
|
expect(flattenToFallbackModelStrings([
|
|
{ model: "anthropic/claude-sonnet-4-6(low)", variant: "high" },
|
|
])).toEqual(["anthropic/claude-sonnet-4-6(high)"])
|
|
})
|
|
|
|
it("explicit variant overrides space-suffix variant", () => {
|
|
expect(flattenToFallbackModelStrings([
|
|
{ model: "openai/gpt-5.4 high", variant: "low" },
|
|
])).toEqual(["openai/gpt-5.4(low)"])
|
|
})
|
|
|
|
it("explicit variant overrides minimal space-suffix variant", () => {
|
|
expect(flattenToFallbackModelStrings([
|
|
{ model: "openai/gpt-5.4 minimal", variant: "low" },
|
|
])).toEqual(["openai/gpt-5.4(low)"])
|
|
})
|
|
|
|
it("preserves trailing non-variant suffixes when adding explicit variant", () => {
|
|
expect(flattenToFallbackModelStrings([
|
|
{ model: "openai/gpt-5.4 preview", variant: "low" },
|
|
])).toEqual(["openai/gpt-5.4 preview(low)"])
|
|
})
|
|
|
|
it("flattens object without variant", () => {
|
|
expect(flattenToFallbackModelStrings([
|
|
{ model: "openai/gpt-5.4" },
|
|
])).toEqual(["openai/gpt-5.4"])
|
|
})
|
|
|
|
it("handles mixed array", () => {
|
|
expect(flattenToFallbackModelStrings([
|
|
"anthropic/claude-sonnet-4-6",
|
|
{ model: "openai/gpt-5.4", variant: "high" },
|
|
{ model: "google/gemini-3-flash(low)" },
|
|
])).toEqual([
|
|
"anthropic/claude-sonnet-4-6",
|
|
"openai/gpt-5.4(high)",
|
|
"google/gemini-3-flash(low)",
|
|
])
|
|
})
|
|
})
|
|
|
|
describe("findMostSpecificFallbackEntry", () => {
|
|
it("picks exact match over prefix match", () => {
|
|
const chain = [
|
|
{ providers: ["openai"], model: "gpt-5.4" },
|
|
{ providers: ["openai"], model: "gpt-5.4-preview" },
|
|
]
|
|
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview", chain)
|
|
expect(result?.model).toBe("gpt-5.4-preview")
|
|
})
|
|
|
|
it("returns prefix match when no exact match exists", () => {
|
|
const chain = [
|
|
{ providers: ["openai"], model: "gpt-5.4" },
|
|
]
|
|
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview", chain)
|
|
expect(result?.model).toBe("gpt-5.4")
|
|
})
|
|
|
|
it("returns undefined when no entry matches", () => {
|
|
const chain = [
|
|
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
|
]
|
|
expect(findMostSpecificFallbackEntry("openai", "gpt-5.4", chain)).toBeUndefined()
|
|
})
|
|
|
|
it("sorts by matched prefix length, not insertion order", () => {
|
|
// Both entries share the same provider so both match as prefixes;
|
|
// the longer (more-specific) prefix must win regardless of array order.
|
|
const chain = [
|
|
{ providers: ["openai"], model: "gpt-5" },
|
|
{ providers: ["openai"], model: "gpt-5.4-preview" },
|
|
]
|
|
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview-2026", chain)
|
|
expect(result?.model).toBe("gpt-5.4-preview")
|
|
})
|
|
|
|
it("is case-insensitive", () => {
|
|
const chain = [
|
|
{ providers: ["OpenAI"], model: "GPT-5.4" },
|
|
]
|
|
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview", chain)
|
|
expect(result?.model).toBe("GPT-5.4")
|
|
})
|
|
|
|
it("preserves variant and settings from matched entry", () => {
|
|
const chain = [
|
|
{ providers: ["openai"], model: "gpt-5.4", variant: "high", temperature: 0.7 },
|
|
{ providers: ["openai"], model: "gpt-5.4-preview", variant: "low", reasoningEffort: "medium" },
|
|
]
|
|
const result = findMostSpecificFallbackEntry("openai", "gpt-5.4-preview", chain)
|
|
expect(result).toEqual({
|
|
providers: ["openai"],
|
|
model: "gpt-5.4-preview",
|
|
variant: "low",
|
|
reasoningEffort: "medium",
|
|
})
|
|
})
|
|
})
|
|
|
|
// Regression: type-guard against non-string model field (issue #4145).
|
|
// Crash signature was `model.trim is not a function` aborting session.processor
|
|
// for every provider when a caller forwarded a FallbackModelObject where a
|
|
// plain string was expected.
|
|
describe("parseFallbackModelEntry: non-string input (issue #4145)", () => {
|
|
test("returns undefined when caller forwards an object instead of a string", () => {
|
|
//#given
|
|
const wrong = { model: "anthropic/claude-opus-4-7", variant: "high" } as unknown as string
|
|
|
|
//#when
|
|
const parsed = parseFallbackModelEntry(wrong, "anthropic")
|
|
|
|
//#then: must not throw, must reject the malformed entry
|
|
expect(parsed).toBeUndefined()
|
|
})
|
|
|
|
test("returns undefined for null and undefined", () => {
|
|
//#given
|
|
const nullInput = null as unknown as string
|
|
const undefinedInput = undefined as unknown as string
|
|
|
|
//#when / #then
|
|
expect(parseFallbackModelEntry(nullInput, "anthropic")).toBeUndefined()
|
|
expect(parseFallbackModelEntry(undefinedInput, "anthropic")).toBeUndefined()
|
|
})
|
|
|
|
test("parseFallbackModelObjectEntry returns undefined when nested model field is non-string", () => {
|
|
//#given: FallbackModelObject whose .model was somehow forwarded as a nested
|
|
//object instead of a flat string (issue #4145 reproduction).
|
|
const malformedObject = {
|
|
model: ({ model: "claude-opus-4-7" } as unknown) as string,
|
|
variant: "high",
|
|
}
|
|
|
|
//#when
|
|
const parsed = parseFallbackModelObjectEntry(malformedObject, "anthropic")
|
|
|
|
//#then: must not throw, must reject the malformed entry
|
|
expect(parsed).toBeUndefined()
|
|
})
|
|
|
|
test("buildFallbackChainFromModels skips entries whose model is a number", () => {
|
|
//#given
|
|
const fallbackModels = [
|
|
"openai/gpt-5.5",
|
|
(42 as unknown) as string,
|
|
]
|
|
|
|
//#when
|
|
const chain = buildFallbackChainFromModels(fallbackModels, "openai")
|
|
|
|
//#then: only the valid string survives, the number is dropped without crashing
|
|
expect(chain).toEqual([
|
|
{
|
|
providers: ["openai"],
|
|
model: "gpt-5.5",
|
|
variant: undefined,
|
|
},
|
|
])
|
|
})
|
|
})
|