fix(core): filter unsupported media inputs (#38145)

Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot] 2026-07-21 13:44:10 -05:00 committed by GitHub
commit caf727ecb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 163 additions and 14 deletions

View file

@ -49,7 +49,14 @@ const client = Layer.mock(LLMClient.Service)({
generate: () => Effect.die("unused"),
})
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(

View file

@ -68,7 +68,13 @@ const client = Layer.mock(LLMClient.Service)({
})
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost,
}),
),
})
const it = testEffect(
AppNodeBuilder.build(

View file

@ -65,7 +65,14 @@ const client = Layer.mock(LLMClient.Service)({
return response
}),
})
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const builtins = Layer.mock(InstructionBuiltIns.Service, {
load: () =>
Effect.succeed(

View file

@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test"
import { Message, ToolResultPart } from "@opencode-ai/ai"
import { unsupportedParts } from "@opencode-ai/core/session/model-request"
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
describe("SessionModelRequest.unsupportedParts", () => {
test("replaces unsupported user media with a visible error", () => {
const messages = unsupportedParts(
[
Message.user([
Message.text("Describe this image"),
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "logo.png" },
]),
],
capabilities(["text"]),
)
expect(messages[0]?.content).toEqual([
Message.text("Describe this image"),
Message.text('ERROR: Cannot read "logo.png" (this model does not support image input). Inform the user.'),
])
})
test("replaces unsupported media nested in tool results", () => {
const messages = unsupportedParts(
[
Message.tool(
ToolResultPart.make({
id: "call_1",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "logo.png" },
],
},
}),
),
],
capabilities(["text"]),
)
expect(messages[0]?.content[0]).toMatchObject({
type: "tool-result",
result: {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{
type: "text",
text: 'ERROR: Cannot read "logo.png" (this model does not support image input). Inform the user.',
},
],
},
})
})
test("preserves supported media", () => {
const message = Message.user({ type: "media", mediaType: "image/png", data: "aGVsbG8=" })
expect(unsupportedParts([message], capabilities(["text", "image"]))[0]?.content).toEqual(message.content)
})
})

View file

@ -73,7 +73,14 @@ const model = OpenAIChat.route
generation: { maxTokens: 20, temperature: 0 },
})
.model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })

View file

@ -283,10 +283,11 @@ let currentModel = model
const models = SessionRunnerModel.layerWith((session) =>
modelResolveHook.pipe(
Effect.as(
SessionRunnerModel.resolved(
session.model?.id === "replacement" ? replacementModel : currentModel,
session.model?.variant,
),
SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
variant: session.model?.variant,
}),
),
),
)

View file

@ -65,7 +65,13 @@ const client = Layer.mock(LLMClient.Service)({
generate: () => Effect.die("unused"),
})
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost,
}),
),
})
const it = testEffect(
AppNodeBuilder.build(