Skip to content

[Bug]: scanned / image-only PDFs are silently dropped on chat channels (rendered page images extracted then discarded) #96541

Description

@yetval

Summary

When a scanned or otherwise image-only PDF (no extractable text layer) is attached to a message on a chat channel (Telegram, WhatsApp, Slack, Discord, etc.), the document is read, its pages are rasterized to images, and then those page images are silently discarded. The model receives only a fixed placeholder string and acts as if the document was blank. The exact same extractor output is forwarded correctly on the /v1/responses API path, so a scanned receipt or signed contract that works over the API is ignored on every chat channel.

Environment

  • Commit: 1adc076 (origin/main at time of filing)
  • Surface: core auto-reply media-understanding path (get-reply -> applyMediaUnderstanding), all chat channels routed through it.

Steps to reproduce

  1. Send a scanned or image-only PDF (a PDF whose extracted text is below tools.media/PDF minTextChars, default 200, so the document extractor falls back to rendering pages to images) to an agent on any chat channel.
  2. The agent replies as if the attachment had no content.

Expected

The rendered page images should be forwarded to the model as inline image content so a vision-capable model can read the scanned document, mirroring how the /v1/responses path already forwards the same images.

Actual

extractFileContentFromSource returns { text: "", images: [page rasters] }, but the chat path replaces the missing text with the literal string [PDF content rendered to images; images not forwarded to model] and pushes only that text block. The page image bytes never reach a forwarded image block. The model sees only the placeholder.

Root cause (if known)

In src/media-understanding/apply.ts (extractFileBlocks), when the extracted text is empty but page images exist, the code sets the placeholder text and pushes only a text block:

// src/media-understanding/apply.ts:496-512 (origin/main 1adc076)
const text = extracted?.text?.trim() ?? "";
let blockText = text ? wrapUntrustedAttachmentContent(text) : "";
if (!blockText) {
  if (extracted?.images && extracted.images.length > 0) {
    blockText = "[PDF content rendered to images; images not forwarded to model]";
  } else {
    blockText = "[No extractable text]";
  }
}
blocks.push(
  renderFileContextBlock({
    filename: bufferResult.fileName,
    fallbackName: `file-${attachment.index + 1}`,
    mimeType,
    content: blockText,
  }),
);

The deeper cause is a structural gap: extractFileBlocks returns string[] (text blocks only) and ApplyMediaUnderstandingResult has no image-carrying field, so the chat turn has no route to forward the page images:

// src/media-understanding/apply.ts:43-50
type ApplyMediaUnderstandingResult = {
  outputs: MediaUnderstandingOutput[];
  decisions: MediaUnderstandingDecision[];
  appliedImage: boolean;
  appliedAudio: boolean;
  appliedVideo: boolean;
  appliedFile: boolean;
};

renderFileContextBlock (src/media/file-context.ts) only accepts a content (text) field, so even at the block-builder level there is no slot for the images.

Sibling surfaces

This is a gap, not a product decision, proven by the /v1/responses API path consuming the SAME extractor output and forwarding the images:

// src/gateway/openresponses-http.ts:629-630
if (file.images && file.images.length > 0) {
  images = images.concat(file.images);
}

Same extractFileContentFromSource output, opposite handling: a scanned PDF is read over the API but ignored on Telegram/WhatsApp/Slack/Discord and every other chat channel. The document extractor itself confirms images are produced for exactly this case (it renders pages to images when extracted text is below minTextChars, default 200), so the page images exist and are simply dropped before the model. Note this is distinct from issue #96389, which is about the clawpdf mode: "images" render budget producing degenerate 1x1 images; that issue presupposes the images would reach a vision model, whereas on the chat path they are never forwarded at all.

Real behavior proof

Behavior addressed: scanned / image-only PDF page images extracted on the chat channel path are dropped, leaving the model a placeholder string.
Real environment tested: real runtime driven at commit 1adc076. The real extractFileContentFromSource ran end to end (real base64 canonicalization, real PDF MIME validation, real extractPdfContent); only the raw page-render boundary extractDocumentContent was stubbed to return one image-only page, since rendering real raster bytes is the one true external boundary. The exact apply.ts:496-512 decision branch was then driven against the real extractor output, calling the real renderFileContextBlock. The drop branch (extractFileBlocks itself is module-internal) was reproduced by executing its verbatim decision code on the real extractor result.
Exact steps or command run after this patch: PATH=/opt/node/bin:$PATH node scripts/run-vitest.mjs src/media-understanding/scanned-pdf-drop.repro.test.ts ; then read /tmp/openclaw-bug-repros/scanned-pdf-drop/observed.json
Evidence after fix:

# OBSERVED (buggy, origin/main 1adc076)
{
  "extractorText": "",
  "extractorImageCount": 1,
  "extractorImageMime": "image/png",
  "extractorImageBytesB64": "RkFLRV9TQ0FOTkVEX1BBR0VfUE5HX0JZVEVT",
  "blockTextChosen": "[PDF content rendered to images; images not forwarded to model]",
  "forwardedBlock": "<file name=\"receipt-scan.pdf\" mime=\"application/pdf\">\n[PDF content rendered to images; images not forwarded to model]\n</file>",
  "forwardedBlockCarriesImageBytes": false
}

# EXPECTED (correct behavior on identical inputs, matching openresponses-http.ts:629-630)
The single extracted image/png page raster (extractorImageBytesB64 above) should be
forwarded to the model as an inline image content block, so a vision model can read the
scanned page instead of receiving only the placeholder string.

Observed result after fix: the real extractor produced one image/png page raster, but the only block forwarded to the model is a text block containing the placeholder and zero image bytes (forwardedBlockCarriesImageBytes is false).
What was not tested: the live channel transport end to end (a real Telegram/WhatsApp send) was not exercised; the rendering boundary extractDocumentContent was stubbed rather than running clawpdf on real raster bytes; the colocated suite src/media-understanding/apply.test.ts has no case asserting this drop is intentional (no reference to "rendered to images", "images not forwarded", or "scanned").

Suggested direction

Add an image channel to the chat path so extracted PDF page images are forwarded, mirroring openresponses-http.ts:629-630. Concretely: let extractFileBlocks return extracted images alongside its text blocks (or carry them on ApplyMediaUnderstandingResult), and have the chat turn append them as inline image content blocks for vision-capable models, replacing the dead-end placeholder string. No patch is attached here.

Metadata

Metadata

Assignees

Labels

P2Normal backlog priority with limited blast radius.clawsweeper:fix-shape-clearClawSweeper found a clear likely implementation shape for this issue.clawsweeper:queueable-fixClawSweeper marked this issue as an existing queue_fix_pr work candidate.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:data-lossCan lose, corrupt, or silently drop user/session/config data.impact:message-lossChannel message delivery can be lost, duplicated, or misrouted.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.no-staleExclude from stale automation

Type

No type

Fields

Priority

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions