fix(core): stop an empty capability list from stripping image input (#13583)

`modelHasCapability` documents a missing or empty capability list as
carrying no signal, so each gate declares its own default. Two readers
bypassed it and read `capabilities` directly, where an empty list is not
nullish but `[].includes(x)` is false:

- the session runtime's `modelSupportsImages` metadata used
  `capabilities?.includes("images") ?? true`, so the intended fail-open
  never fired for an empty list and the file-read tool silently dropped
  every image from the request;
- `toProviderModel` projected an empty list onto `false`, telling pickers
  a model definitively lacks vision, attachments, and reasoning when
  nothing had been declared.

Both now route through the shared helpers, which state their unspecified
default explicitly: `modelSupportsImageInput` fails open for a capability
gate, and `declaredCapability` preserves `undefined` for `ProviderModel`'s
tri-state booleans. A populated list stays authoritative in both.

A thinking config now short-circuits `supportsReasoning` instead of being
OR-ed with the capability read, so its absence no longer collapses the
tri-state to `false`.

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
This commit is contained in:
Dominic Cooney
2026-09-01 06:58:38 +09:00
committed by GitHub
parent a7a57509e2
commit f5370ad4cf
8 changed files with 133 additions and 5 deletions
@@ -906,6 +906,51 @@ it("derives tool image support metadata from resolved provider model catalog", a
expect(runtimeConfig.toolContextMetadata?.telemetry).toBeUndefined();
});
it.each([
["absent", undefined],
["empty", []],
])("keeps image support enabled when the capability list is %s", async (_label, capabilities) => {
const { deps, configs } = withCapturingFakeRuntime();
const session = new SessionRuntime(
makeAgentConfig({
knownModels: {
"claude-3-5-sonnet": {
id: "claude-3-5-sonnet",
...(capabilities === undefined ? {} : { capabilities }),
},
},
}),
deps,
);
await session.run("inspect image");
expect(configs[0]?.toolContextMetadata).toEqual(
expect.objectContaining({ modelSupportsImages: true }),
);
});
it("disables image support when a populated capability list omits images", async () => {
const { deps, configs } = withCapturingFakeRuntime();
const session = new SessionRuntime(
makeAgentConfig({
knownModels: {
"claude-3-5-sonnet": {
id: "claude-3-5-sonnet",
capabilities: ["tools", "prompt-cache"],
},
},
}),
deps,
);
await session.run("inspect image");
expect(configs[0]?.toolContextMetadata).toEqual(
expect.objectContaining({ modelSupportsImages: false }),
);
});
describe("SessionRuntime.run", () => {
it("invokes the injected AgentRuntime and returns an AgentResult", async () => {
const { deps, calls } = withFakeRuntime({
@@ -46,6 +46,7 @@ import {
type MessageWithMetadata,
type ModelInfo,
mergeModelOptions,
modelSupportsImageInput,
modelSupportsToolCalling,
type ToolCallRecord,
usesImageGenerationOperation,
@@ -890,8 +891,7 @@ export class SessionRuntime {
telemetry: this.telemetry,
tools,
toolContextMetadata: {
modelSupportsImages:
modelInfo?.capabilities?.includes("images") ?? true,
modelSupportsImages: modelSupportsImageInput(modelInfo ?? {}),
...this.config.toolContextMetadata,
},
hooks: this.createRuntimeHooks(),
@@ -219,6 +219,23 @@ export async function writeModelsFile(
}
}
/**
* Projects one capability onto `ProviderModel`'s tri-state booleans, where
* `undefined` means "not declared" and drives each picker's own default.
* A missing OR empty capability list carries no signal (see
* `modelHasCapability`), so both must stay `undefined` rather than collapsing
* to a `false` that reads as an authoritative denial.
*/
function declaredCapability(
capabilities: ModelInfo["capabilities"],
capability: ModelCapability,
): boolean | undefined {
if (capabilities === undefined || capabilities.length === 0) {
return undefined;
}
return capabilities.includes(capability);
}
export function toProviderModel(
modelId: string,
info: Pick<
@@ -241,10 +258,15 @@ export function toProviderModel(
...(info.contextWindow !== undefined
? { contextWindow: info.contextWindow }
: {}),
supportsAttachments: info.capabilities?.includes("files"),
supportsVision: info.capabilities?.includes("images"),
supportsAttachments: declaredCapability(info.capabilities, "files"),
supportsVision: declaredCapability(info.capabilities, "images"),
// A thinking config is positive evidence on its own; its absence is
// not evidence of absence, so fall back to whatever the capability
// list declares (including "not declared").
supportsReasoning:
info.capabilities?.includes("reasoning") || info.thinkingConfig != null,
info.thinkingConfig != null
? true
: declaredCapability(info.capabilities, "reasoning"),
operationModes: info.operationModes,
inputModalities: info.modalities?.input,
outputModalities: info.modalities?.output,
@@ -959,6 +959,37 @@ describe("addLocalProvider capabilities", () => {
});
});
it.each([
["absent", undefined],
["empty", [] as const],
])("leaves capability support undeclared when the list is %s", (_label, capabilities) => {
expect(
toProviderModel("sparse-model", {
name: "Sparse Model",
...(capabilities === undefined
? {}
: { capabilities: [...capabilities] }),
}),
).toMatchObject({
supportsVision: undefined,
supportsAttachments: undefined,
supportsReasoning: undefined,
});
});
it("reports a populated capability list as authoritative", () => {
expect(
toProviderModel("vision-only", {
name: "Vision Only",
capabilities: ["images"],
}),
).toMatchObject({
supportsVision: true,
supportsAttachments: false,
supportsReasoning: false,
});
});
it("sets supportsVision and supportsAttachments when capability is 'vision'", async () => {
await addLocalProvider(manager, {
providerId: "vision-provider",
+1
View File
@@ -209,6 +209,7 @@ export {
ModelStatusSchema,
modelHasCapability,
modelProducesImages,
modelSupportsImageInput,
modelSupportsToolCalling,
supportsChatModalities,
type ThinkingConfig,
+1
View File
@@ -234,6 +234,7 @@ export {
ModelStatusSchema,
modelHasCapability,
modelProducesImages,
modelSupportsImageInput,
modelSupportsToolCalling,
supportsChatModalities,
type ThinkingConfig,
@@ -3,6 +3,7 @@ import {
isChatCompatibleModel,
ModelInfoSchema,
modelHasCapability,
modelSupportsImageInput,
modelSupportsToolCalling,
supportsChatModalities,
} from "./model-info";
@@ -138,3 +139,17 @@ describe("modelSupportsToolCalling", () => {
).toBe(false);
});
});
describe("modelSupportsImageInput", () => {
it("fails open when capability metadata is missing or empty", () => {
expect(modelSupportsImageInput({})).toBe(true);
expect(modelSupportsImageInput({ capabilities: [] })).toBe(true);
});
it("trusts a populated capability list", () => {
expect(modelSupportsImageInput({ capabilities: ["images"] })).toBe(true);
expect(
modelSupportsImageInput({ capabilities: ["tools", "prompt-cache"] }),
).toBe(false);
});
});
@@ -220,6 +220,19 @@ export function modelSupportsToolCalling(model: {
return modelHasCapability(model, "tools", { assumeWhenUnspecified: true });
}
/**
* Whether a model can receive image parts in a request. Fails open on the
* same grounds as `modelSupportsToolCalling`: a host boundary that reports no
* capabilities at all has not declared the model text-only, and stripping
* images from a vision-capable model loses user content silently. A populated
* list without `images` is authoritative.
*/
export function modelSupportsImageInput(model: {
capabilities?: readonly string[];
}): boolean {
return modelHasCapability(model, "images", { assumeWhenUnspecified: true });
}
export const ModelInfoSchema = z.object({
id: z.string(),
name: z.string().optional(),