#4873 · Native image results retain non-image timeline items
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
| Claim | Finding | Evidence |
|---|---|---|
| Claude successful image reads remain file-read items. | Verified | Both runs emit completed fileRead with FileText presentation, despite native image content. |
| Pi successful image reads remain generic tool items. | Verified | Both runs emit completed toolCall named read, with result “[image]”. |
| The app provides image preview details for image-view items. | Verified statically | TimelineRowDetails chooses ImageWorkRowBody only for image-generation/image-view. |
| Codex already emits the image-view vocabulary. | Verified statically | Its 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 live | No provider session, host-file HTTP request, or browser was started. |
| Text and failed reads retain their existing behavior. | Verified for controls | Four passing controls per checkout: both text reads at a PNG path and both image-bearing failed reads. |
3. Environment
- Public repository: get-bb/bb; trusted origin/main at 72279d019a9ac82351dd3f7bd70862cbd050a02e, re-fetched before publication with no advance.
- Debian GNU/Linux 12; Linux 4.19.0-gvisor; Node v22.19.0; pnpm 9.15.0; Vitest 4.1.1.
- Frozen repository dependency install in each checkout. First checkout: full Turbo build, 63/63 tasks passed. Verification checkout: focused Turbo dependency build and tests.
- No provider executable invoked. Native result block formats follow the installed repository SDK dependencies. No new dependency added.
- No app/server/daemon, ports, user runtime data, or provider credentials were used. Synthetic paths identify translator inputs only; no filesystem read is needed.
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
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
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.