← reports

#4873 · Native image results retain non-image timeline items

BugLow priorityMedium effortproviders · provider-claude-code · provider-pi GitHub issue · October 4, 2026 · base 72279d019a9ac82351dd3f7bd70862cbd050a02e

PARTIALLY REPRODUCED · Root-cause confidence: high · Reproduction label: partial-repro

1. TL;DR

Two fresh trusted-main checkouts reproduce a concrete classification gap: successful native reads containing image blocks finish as ordinary file/tool items, not image-view items. Claude Code preserves its file-read shape and presentation; Pi preserves a generic read tool call and converts the image content to the text “[image]”. The existing app selects the image preview body by image-view kind, so these outputs do not select that body. This investigation tests translator-to-assembler behavior, not a live model session or browser: the complete visible symptom and host-file route remain unverified. No screenshot is claimed.

2. Claims vs findings

ClaimFindingEvidence
Claude successful image reads remain file-read items.VerifiedBoth runs emit completed fileRead with FileText presentation, despite native image content.
Pi successful image reads remain generic tool items.VerifiedBoth runs emit completed toolCall named read, with result “[image]”.
The app provides image preview details for image-view items.Verified staticallyTimelineRowDetails chooses ImageWorkRowBody only for image-generation/image-view.
Codex already emits the image-view vocabulary.Verified staticallyIts native imageView branch supplies the path and image presentation. No Codex process was exercised.
The reporter's real browser lacks an expandable preview and larger viewer.Unverified liveNo provider session, host-file HTTP request, or browser was started.
Text and failed reads retain their existing behavior.Verified for controlsFour passing controls per checkout: both text reads at a PNG path and both image-bearing failed reads.

3. Environment

4. Minimal reproduction

The regression artifacts are newly authored from repository translator signatures and existing harness patterns. They are not copied from an issue-linked fixture. Download them from this report and put each in its owning package in a clean clone.

git clone https://github.com/get-bb/bb.git /tmp/bb-4873-base
cd /tmp/bb-4873-base
git checkout --detach 72279d019a9ac82351dd3f7bd70862cbd050a02e
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
curl -fL https://get-bb.github.io/reports/issues/4873/repro/claude-image-read.test.ts -o plugins/provider-claude-code/src/image-read-repro.test.ts
curl -fL https://get-bb.github.io/reports/issues/4873/repro/pi-image-read.test.ts -o plugins/provider-pi/src/image-read-repro.test.ts
pnpm exec turbo run test --continue=always --filter=bb-plugin-provider-claude-code --filter=bb-plugin-provider-pi -- src/image-read-repro.test.ts --silent=false

Expected: Each successful image read completes as imageView with the original path and image presentation. Item identity remains stable; failed reads stay failed; PNG paths with text content do not become image views.

Actual, copied from first-run stdout (sorted):

{"provider":"claude-code","item":{"type":"fileRead","id":"cl-test-i1","path":"/workspace/diagram.png","status":"completed","presentation":{"label":{"pending":"Reading file","completed":"Read file"},"icon":{"glyph":"FileText"},"title":"diagram.png"}}}
{"provider":"claude-code","item":{"type":"fileRead","id":"cl-test-i1","path":"/workspace/diagram.png","status":"completed","presentation":{"label":{"pending":"Reading file","completed":"Read file"},"icon":{"glyph":"FileText"},"title":"diagram.png"}}}
{"provider":"claude-code","item":{"type":"fileRead","id":"cl-test-i1","path":"/workspace/diagram.png","status":"failed","presentation":{"label":{"pending":"Reading file","completed":"Read file"},"icon":{"glyph":"FileText"},"title":"diagram.png"}}}
{"provider":"pi","item":{"type":"toolCall","id":"image-read-i1","tool":"read","arguments":{"path":"/workspace/diagram.png"},"status":"completed","result":"[image]"}}
{"provider":"pi","item":{"type":"toolCall","id":"image-read-i1","tool":"read","arguments":{"path":"/workspace/diagram.png"},"status":"completed","result":"plain text"}}
{"provider":"pi","item":{"type":"toolCall","id":"image-read-i1","tool":"read","arguments":{"path":"/workspace/diagram.png"},"status":"failed","result":"[image]"}}

The command exits 1. Each provider has exactly one intended assertion failure and two passing controls: Claude receives fileRead instead of imageView; Pi receives toolCall instead of imageView.

Claude regression artifact

Download test

import { describe, expect, it } from "vitest";
import { createClaudeDeltaHarness } from "./delta-test-harness.js";

const imageContent = [
  {
    type: "image",
    source: {
      type: "base64",
      media_type: "image/png",
      data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aZ5kAAAAASUVORK5CYII=",
    },
  },
];

describe("native image read completion", () => {
  it.each([
    {
      name: "image success",
      content: imageContent,
      failed: false,
      expected: "imageView",
    },
    {
      name: "text at image path",
      content: [{ type: "text", text: "plain text" }],
      failed: false,
      expected: "fileRead",
    },
    {
      name: "image-bearing failure",
      content: imageContent,
      failed: true,
      expected: "fileRead",
    },
  ])("Claude: $name", ({ content, failed, expected }) => {
    const harness = createClaudeDeltaHarness();
    const started = harness.translate({
      type: "assistant",
      session_id: "image-read-session",
      message: {
        role: "assistant",
        content: [
          {
            type: "tool_use",
            id: "native-read",
            name: "Read",
            input: { file_path: "/workspace/diagram.png" },
          },
        ],
      },
    });
    const events = harness.translate({
      type: "user",
      session_id: "image-read-session",
      message: {
        role: "user",
        content: [
          {
            type: "tool_result",
            tool_use_id: "native-read",
            is_error: failed,
            content,
          },
        ],
      },
    });
    const opened = started.find((event) => event.type === "item/started");
    const completed = events.find((event) => event.type === "item/completed");
    expect(opened?.type).toBe("item/started");
    expect(completed?.type).toBe("item/completed");
    if (
      opened?.type !== "item/started" ||
      completed?.type !== "item/completed"
    ) {
      throw new Error("Expected one native read lifecycle");
    }
    console.log(
      JSON.stringify({ provider: "claude-code", item: completed.item }),
    );
    expect(completed.item.id).toBe(opened.item.id);
    expect(completed.item.status).toBe(failed ? "failed" : "completed");
    expect(completed.item).toMatchObject({
      type: expected,
      path: "/workspace/diagram.png",
    });
    if (expected === "imageView") {
      expect(completed.item.presentation).toMatchObject({
        label: { pending: "Viewing image", completed: "Viewed image" },
        icon: { glyph: "Eye" },
      });
    }
  });
});

Pi regression artifact

Download test

import { describe, expect, it } from "vitest";
import { experimental_createDeltaAssembler as createDeltaAssembler } from "@get-bb/plugin-sdk/provider-bridge/testing";
import {
  createPiDeltaTranslator,
  createPiModelContextWindowResolverFrom,
} from "./delta-translation.js";

const imageContent = [
  {
    type: "image",
    mimeType: "image/png",
    data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aZ5kAAAAASUVORK5CYII=",
  },
];

describe("native image read completion", () => {
  it.each([
    {
      name: "image success",
      content: imageContent,
      failed: false,
      expected: "imageView",
    },
    {
      name: "text at image path",
      content: [{ type: "text", text: "plain text" }],
      failed: false,
      expected: "toolCall",
    },
    {
      name: "image-bearing failure",
      content: imageContent,
      failed: true,
      expected: "toolCall",
    },
  ])("Pi: $name", ({ content, failed, expected }) => {
    const translator = createPiDeltaTranslator({
      resolveModelContextWindow: createPiModelContextWindowResolverFrom([]),
    });
    const assembler = createDeltaAssembler({
      providerId: "pi",
      entropyPrefix: "image-read",
      textDeltaFlushMs: 0,
    });
    const translate = (event: unknown) =>
      assembler.assemble({
        threadId: "image-read-thread",
        deltas: translator.translate(event, { cwd: "/workspace" }),
      });
    translate({ type: "agent_start" });
    const started = translate({
      type: "tool_execution_start",
      toolCallId: "native-read",
      toolName: "read",
      args: { path: "/workspace/diagram.png" },
    });
    const events = translate({
      type: "tool_execution_end",
      toolCallId: "native-read",
      toolName: "read",
      result: { content },
      isError: failed,
    });
    const opened = started.find((event) => event.type === "item/started");
    const completed = events.find((event) => event.type === "item/completed");
    expect(opened?.type).toBe("item/started");
    expect(completed?.type).toBe("item/completed");
    if (
      opened?.type !== "item/started" ||
      completed?.type !== "item/completed"
    ) {
      throw new Error("Expected one native read lifecycle");
    }
    console.log(JSON.stringify({ provider: "pi", item: completed.item }));
    expect(completed.item.id).toBe(opened.item.id);
    expect(completed.item.status).toBe(failed ? "failed" : "completed");
    expect(completed.item.type).toBe(expected);
    if (expected === "imageView") {
      expect(completed.item).toMatchObject({ path: "/workspace/diagram.png" });
      expect(completed.item.presentation).toMatchObject({
        label: { pending: "Viewing image", completed: "Viewed image" },
        icon: { glyph: "Eye" },
      });
    }
  });
});

5. Root cause

Claude classifies the start, then retains it at completion

Read classifier returns fileRead for a valid native path. Completion extracts result text, preserves base.shape, and preserves base.presentation. terminalToolShape only specializes delegation summaries; it does not examine image content.

        started ??
        classifyClaudeToolResultFallback(result.toolName, sessionCwd);
      const status = result.isError ? "failed" : "completed";
      deltas.push({
        kind: "item.close",
        key: { providerItemId: result.toolUseId, ...parentRefField },
        status,
        ...terminalCloseFields(base.shape, outputText, result.isError),
        item: terminalToolShape(base.shape, outputText),
        presentation: base.presentation,
      });
      if (resultToolName !== undefined) {
        const planSteps = foldClaudeTaskToolResult({

Pi only keeps the remembered shape

Pi tool classification specializes command and file-change tools, not native read. Completion extracts text and closes the remembered generic shape without promoting successful image-bearing results.

        const resultText = extractResultText(piEvent.data.result);
        const aggregatedOutput = PI_COMMAND_TOOL_NAMES.has(
          piEvent.data.toolName,
        )
          ? extractPiCommandExecutionOutput(piEvent.data.result)
          : undefined;
        const shapeKey = toolShapeKey(context, piEvent.data.toolCallId);
        const terminalShape =
          startedToolShapes.get(shapeKey) ??
          classifyPiToolResultFallback(piEvent.data.toolName);
        startedToolShapes.delete(shapeKey);
        return [
          {
            kind: "item.close",
            key: {
              providerItemId: piEvent.data.toolCallId,
              ...parentRefField,
            },
            status: piEvent.data.isError ? "failed" : "completed",
            resultText,
            exitCode: piEvent.data.isError ? 1 : 0,
            ...(aggregatedOutput === undefined ? {} : { aggregatedOutput }),
            item: terminalShape,
            noTurnFallback: noTurnFallbackFor(piEvent.data, context),

Why this affects preview selection

The detail renderer routes image-view to ImageWorkRowBody; file-read returns null. The image body resolves the host path and provides its image-opening button. The confirmed classification mismatch prevents selection of that image-specific body. Browser behavior, image decoding, and remote file availability are separate, untested boundaries.

Codex's native imageView branch shows the existing vocabulary that both providers can target without creating a new UI kind.

6. Proposed fix and autopilot gate

At each provider's completion boundary, validate native result blocks, confirm successful completion and a known native-read path, and promote to imageView with the existing image presentation. Never infer success from an extension alone. Preserve identity, error status, text-read behavior, and unrelated image-returning tools. Add coverage for missing paths, malformed result blocks, mixed text/image content, and overlapping tool IDs in different threads.

No automatic fix PR: completing this issue requires changes to two independently packaged provider implementations (Claude Code and Pi), exceeding this rule's one-existing-subsystem simple-fix gate. No production patch, branch, or push was attempted. The live preview claim is also unverified. The two failing regression artifacts are preserved for a broader authorized fix.

7. Verification

The same agent repeated the reproduction in a second clean temporary clone, detached at the same trusted base commit. Its pre-test git status was empty; only the two newly authored reproduction files were copied afterward. A fresh frozen install preceded the same Turbo test command. This is a second clean run, not independent verification.

Result: second command also exits 1 with exactly two intended failures and four passing controls. The six JSON outputs match the first run byte-for-byte after sorting. No report claim needed correction after the second run.

Existing translator coverage passes on unchanged production code: pnpm exec turbo run test --continue=always --filter=bb-plugin-provider-claude-code --filter=bb-plugin-provider-pi -- src/delta-translation.tool-calls.test.ts src/delta-translation.test.ts — Claude 79 tests, Pi 64 tests, 143 total passed. These suites do not cover successful native image-result promotion.

No linked open PR was found through the issue timeline and open-PR metadata search at the time of investigation. No PR branch or issue-linked script was fetched or executed.

8. Related issues

#2697 concerns canonical attached/referenced file preview and download behavior; #1762 concerns browser screenshot image output. Their GitHub metadata was read; neither substitutes for the reproduced native translator classification gap.

9. Appendix

Log hygiene: temporary checkout paths are normalized and line-ending whitespace is removed. Assertion messages and JSON output are unchanged.

Trust boundary: issue text, commands, links, and suggested scripts were treated as untrusted claims. No issue-provided URL, command, code, branch, or test was used. Only trusted target-repository main code, its locked dependencies, and the agent-authored regression artifacts were run. Initial fixture setup was corrected to supply Pi's required model resolver before collecting the final two runs; that setup failure is not evidence of a product defect.