fix(shared,delegate-task,claude-code-agent-loader): guard model parsers against non-string input (fixes #4145)

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.
This commit is contained in:
MoerAI
2026-05-18 19:22:33 +09:00
parent ea249121d5
commit ae0c106ed4
5 changed files with 75 additions and 0 deletions
@@ -11,6 +11,7 @@ const CLAUDE_CODE_ALIAS_MAP = new Map<string, string>([
function mapClaudeModelString(model: string | undefined): string | undefined {
if (!model) return undefined
if (typeof model !== "string") return undefined
const trimmed = model.trim()
if (trimmed.length === 0) return undefined
@@ -395,3 +395,65 @@ describe("findMostSpecificFallbackEntry", () => {
})
})
})
// 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,
},
])
})
})
+4
View File
@@ -4,6 +4,9 @@ import { normalizeFallbackModels } from "./model-resolver"
import { KNOWN_VARIANTS } from "./known-variants"
function parseVariantFromModel(rawModel: string): { modelID: string; variant?: string } {
if (typeof rawModel !== "string") {
return { modelID: "" }
}
const trimmedModel = rawModel.trim()
if (!trimmedModel) {
return { modelID: "" }
@@ -33,6 +36,7 @@ export function parseFallbackModelEntry(
contextProviderID: string | undefined,
defaultProviderID = "opencode",
): FallbackEntry | undefined {
if (typeof model !== "string") return undefined
const trimmed = model.trim()
if (!trimmed) return undefined
+4
View File
@@ -11,6 +11,9 @@ const KNOWN_VARIANTS = new Set([
])
export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } {
if (typeof rawModelID !== "string") {
return { modelID: "" }
}
const trimmedModelID = rawModelID.trim()
if (!trimmedModelID) {
return { modelID: "" }
@@ -38,6 +41,7 @@ export function parseVariantFromModelID(rawModelID: string): { modelID: string;
export function parseModelString(
model: string,
): { providerID: string; modelID: string; variant?: string } | undefined {
if (typeof model !== "string") return undefined
const trimmedModel = model.trim()
if (!trimmedModel) return undefined
@@ -11,6 +11,9 @@ const KNOWN_VARIANTS = new Set([
])
export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } {
if (typeof rawModelID !== "string") {
return { modelID: "" }
}
const trimmedModelID = rawModelID.trim()
if (!trimmedModelID) {
return { modelID: "" }
@@ -38,6 +41,7 @@ export function parseVariantFromModelID(rawModelID: string): { modelID: string;
export function parseModelString(
model: string,
): { providerID: string; modelID: string; variant?: string } | undefined {
if (typeof model !== "string") return undefined
const trimmedModel = model.trim()
if (!trimmedModel) return undefined