Skip to content

Attached AVIF images are silently dropped from model context in V2 #41722

Open

Description

@Robin1987China

Summary

In OpenCode V2, pasting or @-mentioning an AVIF image attachment in the TUI inserts an [Image N] mention label, but the image is silently dropped when building the model request: to-llm-message.ts only forwards image/png, image/jpeg, image/gif, and image/webp as media, and attachmentContent returns [] for any other image mime. The model sees the [Image N] label but never receives the image content, so it may hallucinate or fail to work with the attachment. The TUI file picker explicitly supports .avif (see local-attachment.ts mime table), so the format is accepted on input but vanishes on output.

Environment

  • opencode version: v2 (next)
  • OS: macOS
  • Install/channel: next

Reproduction

  1. In the V2 TUI, paste a local .avif image path (supported by the attachment picker)
  2. The TUI inserts [Image 1] and shows the attachment
  3. Submit the prompt and inspect the model request: no media part and no text fallback for the image

Related: #17772 requested AVIF support or auto-conversion; the format is still dropped today. Unsupported formats like image/tiff hit the same path.

Activity

  1. github-actions commented on Aug 11, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of or closely related to existing issues. Please check:

  2. feiiiiii5 commented on Oct 1, 2026

    @feiiiiii5

    Adding scope to this report rather than opening a second one, since it is the same attachmentContent fall-through. Two things beyond the image formats:

    1. It is not only images. attachmentContent (packages/core/src/session/runner/to-llm-message.ts:75-83) branches on text/plain, application/x-directory, image/{png,jpeg,gif,webp} and application/pdf, then falls through to return [] for everything else. Mime.detect (packages/core/src/mime.ts:16) returns application/octet-stream for any non-text bytes, and session/prompt.ts:113 uses it when the attachment carries no mime — so an ordinary .docx or .zip attached from disk lands on exactly this path. That is the default, not an edge case.

    2. The message can disappear entirely. The caller is to-llm-message.ts:266-272:

    const content = [
      ...(message.skills ?? []).flatMap((skill) => (skill.text === undefined ? [] : [Message.text(skill.text)])),
      ...(message.text === "" ? [] : [Message.text(message.text)]),
      ...userAttachmentContent(message.files ?? []),
    ]
    if (content.length === 0) return []

    With an unsupported attachment and empty text, the whole user message is dropped and the step is sent with no new input.

    There is already a convention for the fix: SessionModelRequest.unsupportedParts turns input it cannot represent into a visible part — ERROR: Cannot read "logo.png" (this model does not support image input). Inform the user. (model-request.ts:129-136, test "replaces unsupported user media with a visible error"). It only inspects parts that exist, so it never sees an attachment that attachmentContent already returned [] for. The v1 migration renders the same idea as [Attachment unavailable after migration: … (application/octet-stream)] (test/v1-migration.test.ts:198).

    A visible fallback in attachmentContent would make this consistent with both. I can send a PR with that if useful — is that the shape you want, or would you prefer it surface as an explicit error?

  3. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed report. I can confirm this on v2.2.0, and it still happens on the latest dev (b7992598).

    Steps:

    1. With a model that supports image input, paste the path of a small .avif file into the TUI prompt. The prompt shows [Image 1] and a file red.avif chip.
    2. Submit. The request sent to the model has only the text Describe this image: [Image 1] : no image part, no Attached file text, no warning.
    3. Pasting a .png the same way does send an image_url (data:image/png;base64,…) part.
    4. Even with a text-only model, the PNG gets an explicit "this model does not support image input" note, but the AVIF still disappears with nothing in its place.

    The screenshot and recording below show the AVIF turn, where the model received text only, next to the PNG turn, which included the image. The script below reproduces it with a simulated model.

    This matches what you described: to-llm-message.ts only forwards png/jpeg/gif/webp, and attachmentContent returns nothing for other image types, even though the TUI accepts them. Related: #17772 (an earlier AVIF request, where the provider rejected the image instead of OpenCode dropping it).

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    // Reproduces anomalyco/opencode#41722: a pasted AVIF image gets an [Image N]
    // label in the TUI, but the request sent to the model contains no image part.
    // A PNG is pasted the same way as a control.
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    import { execFileSync } from "node:child_process"
    import { mkdirSync } from "node:fs"
    
    const dir = "/tmp/opencode/repro-41722"
    mkdirSync(dir, { recursive: true })
    const avif = `${dir}/red.avif`
    const png = `${dir}/blue.png`
    // tiny real images (requires ffmpeg with libaom)
    execFileSync("ffmpeg", ["-y", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=red:s=64x64", "-frames:v", "1", "-c:v", "libaom-av1", "-still-picture", "1", avif])
    execFileSync("ffmpeg", ["-y", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=blue:s=64x64", "-frames:v", "1", png])
    
    const paste = (text: string) => `\x1b[200~${text}\x1b[201~`
    
    const summarize = (body: unknown) => {
      const json = JSON.stringify(body)
      const last = (body as any)?.messages?.at(-1)
      const content = Array.isArray(last?.content) ? last.content : [last?.content]
      return {
        parts: content.map((p: any) =>
          typeof p === "string" ? `text:${JSON.stringify(p)}` : p?.type === "text" ? `text:${JSON.stringify(p.text)}` : `${p?.type}${p?.image_url ? ":" + String(p.image_url.url).slice(0, 30) : ""}`,
        ),
        hasAvif: json.includes("image/avif"),
        hasPng: json.includes("image/png"),
      }
    }
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        // make the simulated model accept image input, like any vision model
        setup: ({ config }) =>
          Effect.sync(() => {
            const model = (config as any).providers.simulation.models["gpt-sim-model"]
            model.capabilities.input = ["text", "image"]
          }),
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          yield* llm.serve((request, index) => {
            const s = summarize(request.body)
            console.log(`REQUEST ${index}: ${JSON.stringify(s)}`)
            return Stream.make(Llm.text(`Request ${index + 1} parts: ${s.parts.join(" | ")}`))
          })
          yield* ui.type("Describe this image: ")
          yield* ui.type(paste(avif))
          yield* ui.waitFor("[Image 1]")
          yield* ui.screenshot("avif-attached")
          yield* ui.enter()
          yield* ui.waitFor("parts:", { timeout: 20000 })
          yield* Effect.sleep("1 second")
          yield* ui.screenshot("avif-response")
    
          yield* ui.type("And this one: ")
          yield* ui.type(paste(png))
          yield* ui.waitFor("[Image")
          yield* ui.enter()
          yield* ui.waitFor("Request 2", { timeout: 20000 })
          yield* Effect.sleep("1 second")
          yield* ui.screenshot("png-response")
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions