Skip to content

Image (multimodal) input: exec --image, stream-json images, TUI /image across all 3 providers#143

Merged
gnanam1990 merged 38 commits into
mainfrom
image-attachments
Jun 8, 2026
Merged

Image (multimodal) input: exec --image, stream-json images, TUI /image across all 3 providers#143
gnanam1990 merged 38 commits into
mainfrom
image-attachments

Conversation

@gnanam1990

@gnanam1990 gnanam1990 commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds image (multimodal) input end-to-end: a user can attach local image files to a chat turn so a vision-capable model can see them, across all three providers and both input surfaces. Strictly additive — every text-only path is byte-for-byte unchanged. No new module dependencies.

  • Runtime (internal/zeroruntime): optional Message.Images []ImageBlock (ImageBlock{MediaType string; Data []byte} — raw bytes), a NormalizeImageMediaType allow-list helper (png/jpeg[jpg→jpeg]/gif/webp), and SeedMessagesWithImages (SeedMessages delegates with nil — signature unchanged).
  • Providers — all three serialize native image blocks when a user turn carries images, base64-encoding raw bytes: Anthropic image/source.base64 content blocks, Gemini inlineData parts, OpenAI image_url data-URI parts (this required chatMessage.Content string→any; an empty-content omitempty guard keeps text-only requests byte-identical). Image-only turns still emit; only the user role carries images.
  • Capability gate (internal/modelregistry): SupportsVision; when the effective model lacks vision, images are dropped with a warning (never errors the run). Unknown/custom model ids fail closed (drop).
  • CLI (internal/cli): repeatable zero exec --image <path> — reads, sniffs MIME, normalizes, caps at 10 MiB, base64-encodes; threaded into the agent run through the vision gate.
  • stream-json (internal/streamjson): an images field on message input events (whitelisted only on message events; allows empty content when images present), resolved end-to-end into the agent run.
  • TUI (internal/tui): a /image <path> command with removable [img: name] chips above the input (both skins), /image clear, vision-refusal at attach and re-checked at submit; images thread into the turn and clear after.
  • Shared loader (internal/imageinput): one LoadFile (read/sniff/normalize/cap, os.Stat pre-check) used by both the CLI and TUI — no duplicated logic.

Image sources are local files only (no remote URLs / clipboard). Allowed types: png, jpeg, gif, webp.

Test Plan

  • go build ./... / go vet ./... clean
  • go test ./... green (new tests across zeroruntime, all 3 providers, modelregistry, imageinput, cli, streamjson, tui)
  • go test -race ./internal/{cli,tui,imageinput,providers,streamjson,agent}/... green
  • GOOS=windows GOARCH=amd64 go build ./... green
  • git diff --check clean

Reviewer focus

  • OpenAI text-only byte-identical: the Content string→any change keeps an empty-content message's content key omitted (regression-guarded; mutation-tested).
  • End-to-end image path: a --image file and a stream-json images event both reach agent.Options.Images → the provider wire format (covered by TestRunExecStreamJSONImageReachesAgent).
  • Vision gate: non-vision/unknown model → drop + warn (CLI and TUI, incl. TUI submit-time re-check after a model switch).

Summary by CodeRabbit

  • New Features

    • Attach images via --image (zero exec), stream-json stdin, and TUI /image; images are threaded into runs for vision-capable models.
    • Chat messages support image attachments; providers now include images alongside text.
  • Behavior

    • Non-vision models accept runs but drop images with a stderr warning; invalid, unsupported, or oversized images produce usage errors.
  • Tests

    • Extensive tests added for image loading, parsing, CLI flows, provider serialization, agent seeding, and TUI image workflows.
  • Documentation

    • Exec help now documents the --image flag.

KRATOS and others added 30 commits June 8, 2026 17:10
Stream-json message images were parsed and validated but never reached
agent.Options.Images: streamjson.ResolveImages had no production caller,
so images sent over stream-json input were silently dropped.

resolveExecPrompt now also returns the resolved stream-json images
(nil for text input and for image-free stream-json input). The exec
runner merges them with --image attachments before the vision gate, so
both input sources flow through the same drop+warn and the same
agent.Options.Images wiring.

Adds an exec-level end-to-end test that feeds a base64 image on a
stream-json message event via stdin on a vision model and asserts the
image reaches the agent run (the provider request's user turn).
A user could attach an image on a vision model and then switch to a
non-vision model via /model before submitting; the images were threaded
unconditionally into the agent turn, reaching a model that rejects them.

The commandPrompt submit path now re-runs the vision check against the
current effective model. When the active model can't accept images, the
pending images are dropped (not threaded) and an inline notice mirroring
the headless drop+warn wording is appended. Pending image state is still
cleared after submit either way.

Adds a test that attaches on a vision model, switches the model id to a
non-vision one, submits, and asserts the run received no images, an
inline notice was appended, and pending state was cleared.
LoadFile read the whole file into memory before checking the size cap,
so a multi-gigabyte file allocated a huge buffer only to be discarded.

Add an os.Stat size pre-check before os.ReadFile: a file whose size
exceeds MaxImageBytes is rejected with the same 10 MiB-limit error
before any read; a missing file surfaces the same not-found error. The
existing post-read len(data) check is kept as a TOCTOU backstop in case
the file grows between Stat and ReadFile.
The Anthropic and Gemini providers only attach images in their user
branches, but OpenAI funnels every role through a single mapMessage,
which would emit image content-parts for any role carrying Images.

Guard the content-parts path to the user role: a non-user message that
happens to carry Images keeps the plain string/nil content path. Pins
the invariant with TestMapMessageNonUserRolesNeverCarryImages, asserting
assistant/tool/system messages with Images set still serialize plain
content (no content-parts array).
@gnanam1990 gnanam1990 requested review from Vasanthdev2004 and anandh8x and removed request for Vasanthdev2004 June 8, 2026 12:33
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: f626b50f38cb
Changed files (37): internal/agent/loop.go, internal/agent/loop_images_test.go, internal/agent/types.go, internal/cli/app.go, internal/cli/exec.go, internal/cli/exec_images_gate_test.go, internal/cli/exec_images_test.go, internal/cli/exec_parse.go, internal/cli/exec_parse_image_test.go, internal/cli/exec_streamjson_images_test.go, internal/cli/exec_test.go, internal/imageinput/imageinput.go, and 25 more

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Seeds image blocks into the initial user message, deep-clones image bytes for history, adds file and stream-json image inputs with size/type validation, gates usage by model vision capability, maps images into OpenAI/Gemini/Anthropic request formats, and updates CLI/TUI wiring and rendering with tests.

Changes

Multi-provider vision model image input support

Layer / File(s) Summary
End-to-end image handling checkpoint
internal/zeroruntime/*, internal/imageinput/*, internal/streamjson/*, internal/cli/*, internal/tui/*, internal/agent/*, internal/providers/*, internal/zeroline/*, internal/modelregistry/*
Adds runtime image types (ImageBlock, Message.Images, normalization/cloning), image file loader (imageinput.LoadFile, MaxImageBytes), stream-json image decoding (ResolveImages), CLI --image parsing/merge/gating, TUI /image command and chips, agent seeding/cloning using SeedMessagesWithImages and CloneImageBlocks, provider mappings for OpenAI/Gemini/Anthropic to include base64/data-URI inline image parts, and extensive tests validating loads, serialization, gating, and UI behavior.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant CLI_TUI as CLI/TUI
  participant StreamJSON
  participant ImageLoader as imageinput.LoadFile
  participant Registry
  participant Agent
  participant Provider
  User->>CLI_TUI: submit prompt (+ optional --image / /image / stream-json images)
  CLI_TUI->>ImageLoader: Load file paths
  CLI_TUI->>StreamJSON: Parse stdin events (when used)
  CLI_TUI->>Registry: SupportsVision(effectiveModel)
  CLI_TUI->>Agent: run with Options.Images (SeedMessagesWithImages)
  Agent->>Provider: provider.StreamCompletion with mapped multimodal messages
  Provider-->>Agent: stream events / completion
  Agent-->>CLI_TUI: outputs and transcript updates (image chips cleared)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • Vasanthdev2004
  • anandh8x
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: multimodal image input support across CLI, stream-json, and TUI for all three providers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch image-attachments

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/tui/model.go (1)

903-909: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Pending images are not cleared on a failed submit path (No provider configured).

On Line 903, the early return skips the new clear block (Lines 949-950). That leaves staged images in memory and can unintentionally attach them to a later prompt after provider setup.

Proposed fix
 case commandPrompt:
   m.showSplash = false
   m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: command.text})
+  // A prompt submission consumes staged images regardless of run outcome.
+  turnImages := m.pendingImages
+  m.pendingImages = nil
+  m.pendingImageLabels = nil
+
   if m.provider == nil {
     m.transcript = reduceTranscript(m.transcript, transcriptAction{
       kind: actionAppendAssistant,
       text: "No provider configured.",
     })
     return m, nil
   }
@@
-  turnImages := m.pendingImages
   if len(turnImages) > 0 && !modelSupportsVisionTUI(m.modelName) {
@@
-  m.pendingImages = nil
-  m.pendingImageLabels = nil
   runCtx, cancel := context.WithCancel(m.ctx)

Also applies to: 931-950

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/model.go` around lines 903 - 909, When bailing out because
m.provider == nil in the submit path, clear any staged/pending image state
before returning so images aren't retained for future prompts; specifically, in
the early-return block around the reduceTranscript call (where
transcriptAction/actionAppendAssistant is used) set the staged image field(s)
(e.g., m.stagedImages = nil or make empty) and any related pending image fields
to an empty slice or nil, then proceed with the existing reduceTranscript and
return; apply the same clear-before-return fix to the other early-return branch
around lines 931-950.
internal/streamjson/streamjson.go (1)

190-202: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Image-only message flow still fails at prompt resolution.

Allowing empty content when images are present (Line 257) conflicts with ResolvePrompt, which still errors when no non-empty text exists. That breaks valid image-only message events during stream-json execution.

💡 Proposed fix
 func ResolvePrompt(events []InputEvent) (string, error) {
 	parts := []string{}
+	hasImages := false
 	for _, event := range events {
+		if len(event.Images) > 0 {
+			hasImages = true
+		}
 		content := strings.TrimSpace(event.Content)
 		if content != "" {
 			parts = append(parts, content)
 		}
 	}
 	if len(parts) == 0 {
+		if hasImages {
+			return "", nil
+		}
 		return "", ProtocolError{"Stream-json input must include at least one prompt or user message event."}
 	}
 	return strings.Join(parts, "\n\n"), nil
 }

Also applies to: 257-258

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/streamjson/streamjson.go` around lines 190 - 202, ResolvePrompt
currently errors if no non-empty text parts are found; update it to permit
image-only events by checking InputEvent for image fields (e.g., event.Image,
event.Images, or similar image-related properties used in stream-json) — if
parts is empty but any event contains an image, return "" with nil error instead
of ProtocolError. Modify ResolvePrompt (and any callers if needed) so it treats
presence of images as a valid input, referencing the ResolvePrompt function and
the InputEvent struct's content and image fields to locate the change.
🧹 Nitpick comments (1)
internal/streamjson/streamjson_test.go (1)

219-241: ⚡ Quick win

Add a regression test for image-only ResolvePrompt behavior.

Current coverage validates parsing image-only messages but does not assert the prompt-resolution step for the same input shape. Please add a test that parses an image-only message and verifies ResolvePrompt behavior explicitly.

Also applies to: 303-342

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/streamjson/streamjson_test.go` around lines 219 - 241, Extend the
existing image-only test to also exercise prompt resolution: after calling
ParseInput on the image-only JSON (the existing variable imageOnly), call
ResolvePrompt on the returned events (or the first event) and assert the
behavior you expect for image-only messages (e.g., that ResolvePrompt returns a
non-empty resolved prompt structure or an error-free result and preserves the
image in Events[0].Images). Update TestValidateInputEventAllowsImageOnlyMessage
(or add a new test like TestResolvePromptAllowsImageOnlyMessage) to include the
ParseInput -> ResolvePrompt sequence and corresponding assertions referencing
ParseInput and ResolvePrompt to ensure the regression is covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/agent/loop.go`:
- Line 54: copyMessages currently only deep-copies ToolCalls but leaves
Message.Images and ImageBlock.Data aliased, which causes binary image payloads
seeded by SeedMessagesWithImages to be mutated across copies; update the
copyMessages routine to also deep-copy the Images slice for each Message and
allocate new ImageBlock instances copying the Data byte slices (make a new
[]byte and copy contents) so Message.Images and each ImageBlock.Data are fully
independent across cloned messages.

In `@internal/cli/exec_parse.go`:
- Around line 69-70: The current argument parsing case that appends to
options.imagePaths accepts `--image=` with an empty value; modify the parsing
branch (the case handling strings.HasPrefix(arg, "--image=")) to extract the
value via strings.TrimSpace(strings.TrimPrefix(arg, "--image=")) and if the
resulting value is empty, return a parse error (or propagate an error)
indicating the `--image` flag requires a non-empty value instead of appending it
to options.imagePaths; otherwise append the non-empty value as before.

In `@internal/cli/exec.go`:
- Around line 482-490: Resolve images before resolving the prompt: call
streamjson.ResolveImages(events) first and capture streamImages, then call
streamjson.ResolvePrompt(events). If ResolveImages returns an error, return it
wrapped as execUsageError as before; if ResolvePrompt returns an error but
streamImages is non-empty (i.e., valid image-only input), return an empty prompt
string with the resolved streamImages instead of failing; otherwise propagate
the ResolvePrompt error via execUsageError. Update the variables prompt,
streamImages and error handling around ResolveImages/ResolvePrompt accordingly
(references: ResolveImages, ResolvePrompt, streamImages, prompt,
execUsageError).

In `@internal/imageinput/imageinput.go`:
- Around line 43-51: The current os.ReadFile(resolved) performs an unbounded
read and can allocate beyond MaxImageBytes before the later length check;
replace it with an explicit bounded read using os.Open(resolved) and
io.LimitReader (or io.ReadAll on an io.LimitReader) to read at most
MaxImageBytes+1 bytes, then error if len(data) > MaxImageBytes; update the code
paths that return zeroruntime.ImageBlock{} and the error messages (referencing
variables resolved, path, MaxImageBytes and the ImageBlock return) and ensure
the file is closed (defer f.Close()) and any read errors are propagated instead
of the current os.ReadFile error text.

In `@internal/streamjson/streamjson.go`:
- Around line 215-221: Reject oversized base64 payloads before allocating
decoded bytes by using base64.StdEncoding.DecodedLen on len(image.Data) and
comparing that to maxStreamImageBytes; if it exceeds the limit return the same
ProtocolError, otherwise allocate a buffer of that decoded length and decode
into it (replacing base64.StdEncoding.DecodeString(image.Data)). Update the
logic around base64.StdEncoding.DecodeString, image.Data, and
maxStreamImageBytes in the function so the size check happens prior to decoding
and preserve the existing ProtocolError messages.

In `@internal/zeroline/render.go`:
- Around line 594-597: RenderChat's cmdRegion can produce two rows when
d.ImageChips is non-empty, but the body/overlay height math still assumes a
single-row cmd region, causing a one-row overflow; update the height accounting
in RenderChat (and the similar block around the other cmdRegion usage) to
compute cmdRows = 1 (or 2 when d.ImageChips != "") and subtract cmdRows from the
available height instead of a fixed value (e.g., replace the h - 3 assumption
with h - (baseFooterRows + cmdRows) or adjust by one when ImageChips != ""), and
ensure any calls that build the cmdRegion (the branch using
s.mute.Render(d.ImageChips) and padRight) are consistent with the new cmdRows
calculation so body/overlay sizing matches actual emitted rows.

In `@internal/zeroruntime/helpers.go`:
- Around line 43-47: SeedMessagesWithImages currently aliases the caller-owned
images slice into the returned Message (Message.Images = images); instead
deep-copy the slice and each ImageBlock so the returned Message.Images does not
share memory with the caller. Specifically, in SeedMessagesWithImages make a new
slice of ImageBlock of the same length, copy each ImageBlock value, and for each
ImageBlock copy the Data byte slice into a new byte slice (copying the bytes)
before assigning into the new slice; then set Message.Images to that new slice.
This prevents mutations of ImageBlock.Data by the caller from affecting seeded
messages.

---

Outside diff comments:
In `@internal/streamjson/streamjson.go`:
- Around line 190-202: ResolvePrompt currently errors if no non-empty text parts
are found; update it to permit image-only events by checking InputEvent for
image fields (e.g., event.Image, event.Images, or similar image-related
properties used in stream-json) — if parts is empty but any event contains an
image, return "" with nil error instead of ProtocolError. Modify ResolvePrompt
(and any callers if needed) so it treats presence of images as a valid input,
referencing the ResolvePrompt function and the InputEvent struct's content and
image fields to locate the change.

In `@internal/tui/model.go`:
- Around line 903-909: When bailing out because m.provider == nil in the submit
path, clear any staged/pending image state before returning so images aren't
retained for future prompts; specifically, in the early-return block around the
reduceTranscript call (where transcriptAction/actionAppendAssistant is used) set
the staged image field(s) (e.g., m.stagedImages = nil or make empty) and any
related pending image fields to an empty slice or nil, then proceed with the
existing reduceTranscript and return; apply the same clear-before-return fix to
the other early-return branch around lines 931-950.

---

Nitpick comments:
In `@internal/streamjson/streamjson_test.go`:
- Around line 219-241: Extend the existing image-only test to also exercise
prompt resolution: after calling ParseInput on the image-only JSON (the existing
variable imageOnly), call ResolvePrompt on the returned events (or the first
event) and assert the behavior you expect for image-only messages (e.g., that
ResolvePrompt returns a non-empty resolved prompt structure or an error-free
result and preserves the image in Events[0].Images). Update
TestValidateInputEventAllowsImageOnlyMessage (or add a new test like
TestResolvePromptAllowsImageOnlyMessage) to include the ParseInput ->
ResolvePrompt sequence and corresponding assertions referencing ParseInput and
ResolvePrompt to ensure the regression is covered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b43b8e79-d5ae-48e2-ac54-5d0969e0a078

📥 Commits

Reviewing files that changed from the base of the PR and between 9c2e142 and eaabc3f.

📒 Files selected for processing (36)
  • internal/agent/loop.go
  • internal/agent/loop_images_test.go
  • internal/agent/types.go
  • internal/cli/app.go
  • internal/cli/exec.go
  • internal/cli/exec_images_gate_test.go
  • internal/cli/exec_images_test.go
  • internal/cli/exec_parse.go
  • internal/cli/exec_parse_image_test.go
  • internal/cli/exec_streamjson_images_test.go
  • internal/cli/exec_test.go
  • internal/imageinput/imageinput.go
  • internal/imageinput/imageinput_test.go
  • internal/modelregistry/vision.go
  • internal/modelregistry/vision_test.go
  • internal/providers/anthropic/image_test.go
  • internal/providers/anthropic/provider.go
  • internal/providers/gemini/images_test.go
  • internal/providers/gemini/provider.go
  • internal/providers/gemini/types.go
  • internal/providers/openai/provider.go
  • internal/providers/openai/provider_test.go
  • internal/providers/openai/types.go
  • internal/streamjson/streamjson.go
  • internal/streamjson/streamjson_test.go
  • internal/tui/commands.go
  • internal/tui/commands_test.go
  • internal/tui/image_attach.go
  • internal/tui/image_attach_test.go
  • internal/tui/model.go
  • internal/tui/zeroline_view.go
  • internal/zeroline/render.go
  • internal/zeroruntime/helpers.go
  • internal/zeroruntime/image_types_test.go
  • internal/zeroruntime/seed_images_test.go
  • internal/zeroruntime/types.go

Comment thread internal/agent/loop.go
Comment thread internal/cli/exec_parse.go Outdated
Comment thread internal/cli/exec.go Outdated
Comment thread internal/imageinput/imageinput.go Outdated
Comment thread internal/streamjson/streamjson.go
Comment thread internal/zeroline/render.go
Comment thread internal/zeroruntime/helpers.go

@anandh8x anandh8x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One blocking issue:

internal/streamjson/streamjson.go:190-200 accepts image-only message events earlier in validation (content == "" is allowed when len(images) > 0), but ResolvePrompt then drops empty content and errors when there are no text parts. runExec calls ResolvePrompt before ResolveImages (internal/cli/exec.go:505-509), so a valid image-only stream-json input never reaches the agent.

That contradicts the new protocol behavior in this PR: image-only message events are accepted by ParseInput, but zero exec --input-format stream-json rejects them with "must include at least one prompt or user message event." Please either make ResolvePrompt treat image-bearing user messages as a valid empty prompt, or otherwise handle the image-only case in resolveExecPrompt, and add an exec-level regression test for a stream-json message with content:"" and an image.

Everything else I checked looks solid: shared image loader, provider serialization, CLI/TUI vision gating, submit-time TUI re-check, and OpenAI user-role-only image parts. I also ran:

GOCACHE=/tmp/zero-go-cache go test ./...

Add a shared CloneImageBlocks helper in zeroruntime that deep-copies a
slice of ImageBlock including each Data byte slice (returning nil for
nil/empty input). Use it in SeedMessagesWithImages so a seeded user turn
owns an independent copy of the caller's image bytes, and in the agent
loop's copyMessages so image bytes are deep-copied alongside ToolCalls
instead of staying aliased across history/request/result copies.

Tests assert that mutating the caller's original Data bytes after seeding
leaves the seeded message unchanged, and that copyMessages produces
independent image bytes.
Resolve stream-json images before the prompt in the exec input path and
tolerate an empty prompt when images are present, so an image-only
message event (empty content + one image) proceeds with prompt="" and the
image reaches the agent run instead of being rejected. A turn carrying
neither text nor images still errors.

In ResolveImages, reject an oversized payload from its ENCODED length
(DecodedLen) BEFORE the base64 decode so a huge blob never allocates a
decode buffer just to be capped afterward; the post-decode length check
stays as a backstop.

Tests cover the image-only exec turn reaching the agent and the
pre-decode oversize rejection (asserting the size gate fires before any
decode).
Replace os.ReadFile with os.Open + io.ReadAll over an
io.LimitReader(f, MaxImageBytes+1). The os.Stat pre-check is only a
fast-path hint (a non-regular file reports a misleading size and the file
can grow between stat and read); the LimitReader is the real bound, so at
most one byte past the cap is ever buffered and an oversized or unbounded
source can never allocate a multi-gigabyte buffer. The file is closed via
defer and the existing size message is reused.
cmdRegion emits TWO rows when ImageChips is set (a chip row above the
input line), but the body/overlay height accounting assumed a one-row
command region, overflowing the fixed frame by one line. Compute the
fixed-row count from the actual command-region height (1 or 2 rows) so
the composed frame stays exactly Height rows in both cases.

A RenderChat test asserts the rendered frame line-count equals the
requested height both with and without ImageChips set.
Route the inline --image= form through requiredInlineFlagValue, the same
empty-rejection the other inline flags use, so --image= errors with
"--image requires a value" instead of appending an empty image path. A
parse test covers the empty inline value.
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

Thanks — all 7 addressed in 4a3de7a, 98f22b5, c5aff5d, 306823c, 38e9af0. Full gates green (build/vet/test/-race/GOOS=windows).

  • Deep-copy image buffers (loop.go + helpers.go): added zeroruntime.CloneImageBlocks (deep-copies the slice and each Data []byte); SeedMessagesWithImages copies before attaching and copyMessages now clones Images alongside ToolCalls. Test mutates the caller's bytes and asserts seeded/copied turns are unaffected.
  • Image-only stream-json (exec.go): the stream-json branch now resolves images first and only treats an empty prompt as an error when there are no images — so an image-only message event proceeds. New TestRunExecStreamJSONImageOnlyMessageProceeds (empty content + one base64 image → reaches agent.Options.Images).
  • Bounded read (imageinput.go): os.Open + io.ReadAll(io.LimitReader(f, MaxImageBytes+1)) so the read can't over-allocate regardless of a stat/read race or non-regular file.
  • Pre-decode base64 cap (streamjson.go): reject when base64.StdEncoding.DecodedLen(len(data)) > maxStreamImageBytes before DecodeString; post-decode check kept as a backstop.
  • RenderChat height (render.go): cmdRegion is 2 rows when ImageChips != "", so the body/overlay sizing now reserves that extra row; frame stays exactly height. Test asserts the line-count with and without chips (fails 25-vs-24 without the fix).
  • --image= empty: routed through requiredInlineFlagValue → "--image requires a value".

@coderabbitai please re-review.

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e

# Check CloneImageBlocks implementation
echo "=== zeroruntime/types.go ==="
cat internal/zeroruntime/types.go

echo ""
echo "=== zeroruntime/helpers.go ==="
cat internal/zeroruntime/helpers.go

echo ""
echo "=== internal/agent/loop.go (relevant section) ==="
rg -n "CloneImageBlocks|copyMessages|Images" internal/agent/loop.go | head -40

echo ""
echo "=== imageinput/imageinput.go ==="
cat internal/imageinput/imageinput.go

echo ""
echo "=== streamjson/streamjson.go (ResolveImages section) ==="
cat internal/streamjson/streamjson.go

echo ""
echo "=== zeroline/render.go ==="
cat internal/zeroline/render.go

echo ""
echo "=== cli/exec_parse.go (--image= handling) ==="
cat internal/cli/exec_parse.go

echo ""
echo "=== cli/exec.go (stream-json image-only branch) ==="
cat internal/cli/exec.go

Length of output: 116325



@gnanam1990 All six fixes verified clean. Here's the breakdown:

Fix Verdict
Deep-copy (CloneImageBlocks) CloneImageBlocks in helpers.go deep-copies the slice and each Data []byte via append([]byte(nil), block.Data...). SeedMessagesWithImages and copyMessages (loop.go line 918) both use it correctly. Returns nil for nil/empty input, preserving text-only representation. ✅
Image-only stream-json resolveExecPrompt calls ResolveImages first, then ResolvePrompt. perr is only propagated when len(streamImages) == 0; otherwise prompt = "" and the run proceeds. ✅
Bounded read os.Open + io.LimitReader(f, MaxImageBytes+1) with a post-read len(data) > MaxImageBytes backstop. The LimitReader is the real enforcement, Stat is just the fast-path hint. ✅
Pre-decode base64 cap base64.StdEncoding.DecodedLen(len(image.Data)) > maxStreamImageBytes before DecodeString, with post-decode backstop kept. Note: DecodedLen is an upper bound (overestimates by ≤2 bytes due to padding), so this conservatively rejects payloads within ~2 bytes of the limit — entirely acceptable for a 10 MiB cap. ✅
RenderChat height cmdRows defaults to 1, set to 2 when ImageChips != "". fixedRows = cmdRows + 2 (top + cmd-rows + bottom), so bodyH correctly accounts for the extra chip row. cmdRegion emits chips + "\n" + input for exactly 2 rows. ✅
--image= empty Routes through requiredInlineFlagValue(arg, "--image") which returns execUsageError{"--image requires a value"} on empty. ✅

No new issues found. The implementation is solid.

[approve]

🐇 ✨

@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@anandh8x good catch — this was exactly right, and it's now fixed (it overlapped with a CodeRabbit finding so it landed in the same batch, 98f22b5).

I took your second suggested approach — handle the image-only case in resolveExecPrompt rather than changing ResolvePrompt's contract:

streamImages, err := streamjson.ResolveImages(events)   // resolve images first
if err != nil { return "", nil, execUsageError{err.Error()} }
prompt, perr := streamjson.ResolvePrompt(events)
if perr != nil {
    // An image-only message event (content:"" + >=1 image) is valid: ResolvePrompt
    // rejects empty content, but with images present we proceed with an empty prompt.
    if len(streamImages) == 0 {
        return "", nil, execUsageError{perr.Error()}
    }
}
return prompt, streamImages, nil

So an image-only message event now flows through to agent.Options.Images instead of being rejected, while a truly empty event (no text, no images) still errors as before.

Added the exec-level regression test you asked for — TestRunExecStreamJSONImageOnlyMessageProceeds (internal/cli/exec_streamjson_images_test.go): pipes a stream-json message with content:"" and one base64 PNG on a vision model and asserts the image reaches the agent's user turn. Passing; full go test ./... / -race / GOOS=windows green.

Please re-review when you get a chance.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
internal/imageinput/imageinput.go (1)

53-53: 💤 Low value

Consider checking the Close error for completeness.

While ignoring defer file.Close() errors is idiomatic for read-only local files, some filesystems can surface read errors at close time. This is a very minor point given the read error is already checked at line 55.

Optional: check Close error
-	defer file.Close()
+	defer func() { _ = file.Close() }()

Or explicitly handle it if you prefer stricter error checking (though in this context it's not necessary).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/imageinput/imageinput.go` at line 53, Replace the simple defer
file.Close() with a deferred closure that checks and surfaces Close errors:
defer func() { if cerr := file.Close(); cerr != nil { if err == nil { err =
fmt.Errorf("closing file: %w", cerr) } else { /* optionally log:
fmt.Errorf("closing file (suppressed): %v", cerr) */ } } }() so any filesystem
error on Close is returned (or at least logged); reference the existing
file.Close() defer and the function's named return error variable (err) when
locating where to change this in internal/imageinput/imageinput.go.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/imageinput/imageinput.go`:
- Line 53: Replace the simple defer file.Close() with a deferred closure that
checks and surfaces Close errors: defer func() { if cerr := file.Close(); cerr
!= nil { if err == nil { err = fmt.Errorf("closing file: %w", cerr) } else { /*
optionally log: fmt.Errorf("closing file (suppressed): %v", cerr) */ } } }() so
any filesystem error on Close is returned (or at least logged); reference the
existing file.Close() defer and the function's named return error variable (err)
when locating where to change this in internal/imageinput/imageinput.go.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2d222535-8f67-4f74-b4dc-4ff467e2e2aa

📥 Commits

Reviewing files that changed from the base of the PR and between 8e01e1b and 38e9af0.

📒 Files selected for processing (13)
  • internal/agent/loop.go
  • internal/agent/loop_images_test.go
  • internal/cli/exec.go
  • internal/cli/exec_parse.go
  • internal/cli/exec_parse_image_test.go
  • internal/cli/exec_streamjson_images_test.go
  • internal/imageinput/imageinput.go
  • internal/streamjson/streamjson.go
  • internal/streamjson/streamjson_test.go
  • internal/zeroline/render.go
  • internal/zeroline/render_test.go
  • internal/zeroruntime/helpers.go
  • internal/zeroruntime/seed_images_test.go
✅ Files skipped from review due to trivial changes (1)
  • internal/zeroline/render_test.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • internal/cli/exec_parse.go
  • internal/agent/loop.go
  • internal/streamjson/streamjson_test.go
  • internal/streamjson/streamjson.go
  • internal/cli/exec.go

@anandh8x anandh8x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One remaining blocking issue:

internal/imageinput/imageinput.go:36-55 still accepts non-regular filesystem entries. The new bounded read protects memory once reading starts, but os.Open(resolved) can block before the LimitReader is reached for a FIFO/named pipe with no writer. That means zero exec --image <fifo> or TUI /image <fifo> can hang the process/UI indefinitely.

Please reject non-regular files immediately after os.Stat, e.g. if !info.Mode().IsRegular() { return ..., fmt.Errorf("image file must be a regular file: %s", path) }, and add a small loader test for a directory or FIFO/non-regular path. Symlinks to regular files can still work because os.Stat follows them.

The previous image-only stream-json blocker is fixed: images are resolved before prompt text, image-only turns proceed with an empty prompt, and there is exec-level coverage for that path.

Tests run:

GOCACHE=/tmp/zero-go-cache go test ./...

@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@anandh8x fixed in f626b50 — good catch on the FIFO hang. imageinput.LoadFile now rejects non-regular files immediately after os.Stat (which follows symlinks, so a symlink to a regular file still works):

if !info.Mode().IsRegular() {
    return zeroruntime.ImageBlock{}, fmt.Errorf("image file must be a regular file: %s", path)
}

This runs before os.Open, so --image <fifo> / /image <fifo> can't block the process/UI anymore. Added TestLoadFileRejectsNonRegular (points the loader at a directory — a non-regular entry like a FIFO — and asserts the "regular file" rejection). Full build/tests green. Please re-review.

@anandh8x anandh8x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review after f626b50.

The previous blockers are fixed:

  • image-only stream-json turns now resolve images before prompt text and proceed with an empty prompt when images are present.
  • imageinput.LoadFile now rejects non-regular files before opening, so directories/FIFOs/devices cannot hang the CLI/TUI path before the bounded read.

I rechecked the loader, stream-json, CLI image flow, deep-copying, and provider serialization paths. No further blocking issues found.

Tests:

  • GOCACHE=/tmp/zero-go-cache go test ./internal/imageinput ./internal/cli ./internal/streamjson ./internal/zeroruntime ./internal/agent ./internal/zeroline ./internal/providers/openai ./internal/providers/anthropic ./internal/providers/gemini
  • GOCACHE=/tmp/zero-go-cache go test ./...
  • git diff --check origin/main...HEAD

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
internal/imageinput/imageinput.go (1)

51-55: 💤 Low value

Update comment to reflect that non-regular files are already rejected.

The comment mentions "a non-regular file reports a misleading size," but line 44 now rejects non-regular files before reaching this code. The bounded-read rationale should focus on stat/read races for regular files that grow between stat and read.

📝 Suggested comment update
-	// Bounded read: the os.Stat above is only a fast-path hint (a non-regular
-	// file reports a misleading size, and the file can grow between Stat and the
-	// read). A LimitReader of MaxImageBytes+1 is the real bound — at most one byte
-	// past the cap is ever buffered, so an oversized or unbounded source (e.g. a
-	// FIFO) can never allocate a multi-gigabyte buffer just to be discarded.
+	// Bounded read: the os.Stat size is only a fast-path hint (the file can grow
+	// between Stat and the read). A LimitReader of MaxImageBytes+1 is the real
+	// bound — at most one byte past the cap is ever buffered, so an oversized
+	// source can never allocate a multi-gigabyte buffer just to be discarded.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/imageinput/imageinput.go` around lines 51 - 55, Update the comment
above the bounded-read logic to remove the outdated note about "a non-regular
file reports a misleading size" (non-regular files are already rejected earlier)
and instead explain that the LimitReader(MaxImageBytes+1) protects against races
where a regular file can grow between os.Stat and the subsequent read; mention
the intent that at most one byte past the cap is buffered and this prevents
allocating large buffers for oversized/unbounded sources. Reference
MaxImageBytes, LimitReader and os.Stat in the updated comment.
internal/imageinput/imageinput_test.go (1)

87-102: 💤 Low value

Consider adding a test for symlinks to regular files.

The production code comment (imageinput.go:41-42) states that symlinks to regular files should pass. A test verifying this positive case would strengthen coverage and document the intended behavior.

🧪 Suggested test addition
func TestLoadFileSymlinkToRegularFile(t *testing.T) {
	root := t.TempDir()
	png := []byte{
		0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
		0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
		0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
		0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4,
		0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41,
		0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
		0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
		0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
		0x42, 0x60, 0x82,
	}
	target := filepath.Join(root, "pic.png")
	if err := os.WriteFile(target, png, 0o644); err != nil {
		t.Fatalf("write png: %v", err)
	}
	symlink := filepath.Join(root, "link.png")
	if err := os.Symlink(target, symlink); err != nil {
		t.Skipf("symlink not supported: %v", err)
	}

	// Symlink to regular file should succeed (Stat follows symlinks).
	block, err := LoadFile("link.png", root)
	if err != nil {
		t.Fatalf("LoadFile symlink: %v", err)
	}
	if block.MediaType != "image/png" {
		t.Errorf("MediaType = %q, want image/png", block.MediaType)
	}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/imageinput/imageinput_test.go` around lines 87 - 102, Add a new unit
test named TestLoadFileSymlinkToRegularFile to
internal/imageinput/imageinput_test.go that creates a small valid PNG file in
t.TempDir(), writes it as e.g. "pic.png", creates a symlink "link.png" pointing
to that target (skip the test if os.Symlink is unsupported), then call
LoadFile("link.png", root) and assert no error and that the returned
block.MediaType == "image/png"; this verifies that LoadFile follows symlinks to
regular files as documented. Ensure the test uses the same LoadFile function and
temp directory setup pattern as existing tests and includes the small PNG byte
slice and permission 0o644 when writing the target file.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/imageinput/imageinput_test.go`:
- Around line 87-102: Add a new unit test named TestLoadFileSymlinkToRegularFile
to internal/imageinput/imageinput_test.go that creates a small valid PNG file in
t.TempDir(), writes it as e.g. "pic.png", creates a symlink "link.png" pointing
to that target (skip the test if os.Symlink is unsupported), then call
LoadFile("link.png", root) and assert no error and that the returned
block.MediaType == "image/png"; this verifies that LoadFile follows symlinks to
regular files as documented. Ensure the test uses the same LoadFile function and
temp directory setup pattern as existing tests and includes the small PNG byte
slice and permission 0o644 when writing the target file.

In `@internal/imageinput/imageinput.go`:
- Around line 51-55: Update the comment above the bounded-read logic to remove
the outdated note about "a non-regular file reports a misleading size"
(non-regular files are already rejected earlier) and instead explain that the
LimitReader(MaxImageBytes+1) protects against races where a regular file can
grow between os.Stat and the subsequent read; mention the intent that at most
one byte past the cap is buffered and this prevents allocating large buffers for
oversized/unbounded sources. Reference MaxImageBytes, LimitReader and os.Stat in
the updated comment.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 76bdeaa1-2d6c-4114-b181-ac31caf72c59

📥 Commits

Reviewing files that changed from the base of the PR and between 38e9af0 and f626b50.

📒 Files selected for processing (2)
  • internal/imageinput/imageinput.go
  • internal/imageinput/imageinput_test.go

@gnanam1990 gnanam1990 merged commit 68facda into main Jun 8, 2026
6 checks passed
@Vasanthdev2004 Vasanthdev2004 deleted the image-attachments branch June 28, 2026 08:27
@coderabbitai coderabbitai Bot mentioned this pull request Jul 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants