Files
zero/internal/tui/image_attach_test.go
Anandan afb5010a46 polish v2: smoothness & delight (#484)
* feat(tui): actionable hints on provider/model errors

Classify a failed turn's provider error into auth / rate-limit /
connectivity / model-not-found / context-overflow and render a one-line
next step below the red error row, instead of dumping an identical raw
blob for every failure mode.

The classifier lives in a new internal/errhint package so both the TUI
error row and the CLI exec path can share it (TUIHint references slash
commands, CLIHint references zero subcommands). Classification is a
conservative string heuristic keyed off providerio.ClassifiedError's
prefixes plus lower-level DNS/TLS/timeout/context-length signatures,
since the numeric HTTP status is gone by the time the error reaches a UI
surface.

* feat(tools): budget bash output with head+tail truncation

bash was the one tool that returned command output with no byte cap —
'cat large.log' or a verbose test run could dump megabytes into context
and force compaction, while every other read/search tool already applies
a budget.

Cap stdout and stderr at 96KB each, keeping the head and tail of an
oversized stream (build/test failures usually land at the tail) and
dropping the middle behind a marker that points at redirect-to-file +
read_file. Record raw_bytes/emitted_bytes/estimated_tokens/truncated in
Meta like the other tools. Sandbox-denial and shell-issue detection still
run on the full raw output before budgeting.

* fix(gemini): retry 401 with refreshed OAuth token

The Gemini provider used providerio.SendWithRetry (429/503/529 only),
so an expired OAuth token surfaced as a terminal 'API key not valid'
auth error while OpenAI and Anthropic silently recovered via
SendWithAuthRetry's force-refresh-and-replay on 401.

Plumb OAuthResolver through gemini.Options/Provider and the factory
(exactly as the other two providers already do) and switch the stream
send to SendWithAuthRetry. With a nil resolver (API-key users) behavior
is unchanged.

* fix(errhint): gate classification on provider-origin marker

Address CodeRabbit review on #1: agentResponseMsg.err can also carry
local failures (a tool's 'permission denied', a 'file does not exist',
a config error), and broad substrings would have attached a bogus
/provider or /model hint to those.

Classify now returns Unknown unless the message carries a provider marker
the provider layer always attaches (auth error: / rate limit error: /
provider error: / provider request error: / provider stream error:).
Local errors never draw a provider hint; provider errors sub-classify as
before. Added local-failure test cases proving they stay Unknown.

* feat(cli): actionable hint on text-mode exec provider errors

zero exec printed provider failures as a bare '[zero] <raw error>' with
no next step. Append a one-line hint (reusing the shared errhint
classifier) for recognized provider failures — 'run `zero auth`',
'run `zero doctor`', etc. The provider-origin gate means non-provider
codes (sandbox_error, mcp_error) and JSON/stream-json output are
untouched.

* fix(tools): set Result.Truncated for budgeted bash output

Address CodeRabbit review on #3: budgetBashOutput recorded truncation in
meta["truncated"] but never set the Result.Truncated struct field, so
consumers reading the struct (like glob/web_fetch/read_minified do) would
miss truncated bash results. Return the bool from budgetBashOutput and
set Result.Truncated on every return path.

* perf(tui): reuse one LSP manager across prompts

runAgentWithOptions built a fresh lsp.NewManager per run and shut it down
when the run returned, so gopls (and every other language server)
cold-started on the first edit of every turn — 200ms-2s of latency the
manager's own design (long-lived, reused servers) exists to avoid.

Build one manager per session in newModel (cheap: servers start lazily on
first Check) and reuse it across runs; the SelfCorrector still gets a
fresh checker wrapping the shared manager each run. Torn down with a
short deadline in quit(). Runs fall back to a per-run manager when the
session manager is absent (cwd unknown, or a directly-built test model).

* perf(tui): accumulate streamingText as []byte (O(1) append)

m.streamingText += msg.delta was O(len) per delta -> O(n²) across a long
generation (a 10k-token code gen allocates hundreds of MB of intermediate
strings and the UI gets progressively laggier). Accumulate into a []byte
with append (O(1) amortized) and read via streamingTextString().

A []byte rather than strings.Builder because the TUI model is copied by
value on every Update, which would trip strings.Builder's copy check;
[]byte is nil-safe and copies cleanly like the other slice fields.

* perf(tui): coalesce streamed text deltas to one frame

Every OnText delta was its own tea.Msg, so a fast provider (100+ tok/s)
drove 100+ full Update->View cycles per second, each re-parsing the
growing markdown — visible stutter over SSH and wasted CPU.

Batch agentTextMsg deltas at the runtime sink over a ~16ms frame and
forward them as a single message, decoupling render rate from token rate.
Any non-text message flushes pending text first so ordering with
tool-call / reasoning / row messages is preserved; the final
agentResponseMsg (a tea.Cmd return, not a sink message) is safe because
the model already drops deltas for an inactive runID.

* fix(agent): raise tool-failure stop threshold 4->6

guardrails.go halted a run after 4 consecutive same-error tool failures.
A corrective hint fires at 2, and a model iterating on a genuinely tricky
edit can legitimately fail a couple more times after the hint while
converging — stopping at 4 cut those runs short. Raise to 6; the streak
still resets the instant the tool succeeds or hits a different error, so
only true same-error loops are affected.

* fix(agent): more reconnect retries with jitter; narrow to transport errors

#19: bump maxStreamReconnects 2->4 and add up-to-50% jitter on the
exponential backoff (capped at 8s), so a multi-second network blip is
ridden out instead of killing the run on a 2s hiccup, and concurrent runs
(swarms, cron fleets) don't reconnect in lockstep.

#34: drop the '502'/'503' substring matches from shouldReconnect. 503
already exhausted providerio.SendWithRetry (retrying here is a redundant
double-retry) and 502 is non-idempotent by providerio's rule (the POST
may have been processed). Only genuine transport failures — where no
response was received — reconnect now.

* fix(agent): reduce stall retries 2->1 to bound stuck-session time

A no-output stream stall is detected only after the full stream idle
timeout (~5min) elapses, and each retry can idle again — so 2 retries
left an interactive session frozen for ~15min. Drop to 1 retry: keeps the
common single-hiccup recovery while bounding the worst case to ~2x the
idle timeout.

* fix(tui): serialize coalescer forwarding to preserve message order

Address CodeRabbit review on #7: flush() and the run-switch path drained
the text buffer under c.mu but called forward() after releasing it, so a
timer-fired text flush could race a concurrent non-text send() and land
after the tool-call/reasoning message it should precede.

Hold c.mu across drain AND forward (drainAndForwardLocked), so whoever
holds the lock delivers atomically and the other caller blocks until it
is done — text can never overtake a following non-text message. Added a
concurrency stress test (timer racing an inline send) that passes under
-race.

* feat(tui): accept /compact now

Bare /compact already triggers compaction, but /compact now — what users
reach for when the context gauge climbs — hit the usage-error branch.
Accept the 'now' keyword and advertise it in the command usage.

* feat(tui): add /retry, /edit, /copy, /export commands

/retry resends the last prompt; /edit recalls it into the composer to
tweak and resend — both read a new lastPrompt field captured verbatim
(pre-expansion) in launchPrompt. /copy puts the last answer on the
clipboard via the existing copy machinery; /export writes a plain
role-prefixed transcript to a file (timestamped default, or a given
path). Real gaps for SSH users with no mouse select.

* feat(tools): separator-insensitive tool_search matching

tool_search ranked deferred tools by exact substring, so a model that
typed 'webfetch' (dropping the underscore) matched nothing and looped.
Add a separator-squashing fallback ('web_fetch' -> 'webfetch') scored
below an exact substring match, so precise queries still rank first.

* feat(tools): signal ask_user dismissal distinctly from a blank field

Empty answers all rendered as '(no answer provided)', so the model
couldn't tell a wholesale dismissal (user closed the prompt without
answering anything) from one field left blank amid real answers — and
might invent a default. FormatAskUserAnswers now flags a full dismissal
up front as a skip and marks individual empties '(left blank)' vs
'(skipped)'.

* feat(tui): fuzzy ranking in model/session pickers

The pickers filtered by flat strings.Contains with no ranking, so
finding 'sonnet 4.5' among 50+ models meant an exact prefix or scrolling.
Rank matches (exact < prefix < contains < subsequence, reusing
fuzzySubsequenceGap) so the closest match lands on top, and 'snt45' now
matches 'Sonnet 4.5'. Groups stay contiguous — ordered by their best
match, never split — so the grouped model picker still renders one header
per provider. Covers both /model and /resume (shared commandPicker).

* perf(agent): cache per-tool schema render across turns

partitionTools re-ran the recursive schema->map conversion
(schemaToRuntimeMap) for every tool on every turn — redundant, since a
tool's advertised name/description/schema is stable for the run. Add a
per-run definition cache keyed by tool name (nil-safe; the plain
partitionTools entrypoint and tests pass nil for fresh renders).

The partitioning itself (visibility, deferral, ordering) still recomputes
every turn — it must, because a tool's deferred state can flip mid-run
(swarm tools un-defer once a swarm is active). Only the expensive schema
render is memoized. tool_search is excluded by its callers (dynamic
description) so it never poisons the cache.

* perf(agent): calibrate compaction estimate against real prompt tokens

The byte/4 heuristic (ApproxTextTokens) over-counts code-heavy content
~15-20%, so with triggerRatio=0.7 compaction fired at ~60% of true
capacity — premature summarizer calls that degrade quality. Each turn now
folds (rawEstimate, provider InputTokens) into an EMA calibration ratio,
clamped per-sample to a sane band; maybeCompact and the reactive path
scale their estimates by it. Turn 1 uses the raw estimate (no data yet);
later turns compact near real capacity.

* style: gofmt the new agent test files

The CI gofmt check flagged the #22/#25 test files (struct-field and
switch alignment). No behavior change.

* feat(agent): turn-based plan staleness reminder

The stale-plan nudge only fired after 10 tool calls since the last
update_plan, so a plan that drifts stale across many low-tool-call turns
(one call per turn) took many turns to catch — the 'plan set turn 1 stays
pending forever' case. Add a turn-count complement: after
stalePlanTurnThreshold (8) turns without an update AND with items still
pending, fire the same one-shot reminder. Gated on pending items so a
fully-completed plan is never nagged; the existing tool-call trigger is
unchanged. (The roadmap's fuzzy 'tool calls unrelated to plan items'
relevance match is deliberately omitted — too false-positive-prone.)

* feat(agent): preserve recent-edits summary across compaction

Compaction preserved the plan, loaded skills, tool schemas, and project
instructions, but not file edits — so after the editing turns were elided
the model knew it had changed a file only vaguely, and would re-read to
rediscover its own footprint. Extract write_file/edit_file targets from
the elided middle with a one-line note from each tool result, merge them
across repeated compactions (newer note per path wins), and carry them in
the preserved-state JSON (capped at 20 paths / 160 bytes per note).

* feat(tui): workspace-scoped /resume + fewer event reads

/resume listed sessions from every project globally, and read the full
event file for each of them to test for resumable content. Filter the
picker (and /resume latest) by the current workspace Cwd, checked BEFORE
the per-session read so a large global history no longer pays N full file
reads to build one workspace's list; also skip zero-event sessions via
metadata without any read. Sessions with no recorded Cwd (older runs) and
an unknown current workspace stay visible so nothing is hidden that can't
be confidently placed. Explicit /resume <id> still resolves any session.

* feat(tui): warn on model switch that drops staged images

Staged images were silently dropped at submit when the active model had
no vision support — the user only learned after sending. Chips already
render above the composer (renderAttachmentChips); add an immediate amber
warning line to the /model switch status (both same-provider and
cross-provider paths) when images are staged and the newly selected model
can't accept them, so the drop is surfaced at switch time.

* feat(tui): inline recovery affordance on setup errors

A first-run setup error rendered as a bare red 'error: <text>' with no
pointer to the fix. Append a faint, stage-tailored recovery line (edit
the endpoint/name/key above then Enter, or pick another model) that names
only keys the stage's footer confirms, so the guidance is always accurate.
Empty for stages whose footer already makes recovery obvious.

* fix: address CodeRabbit review (5 findings)

- loop.go: recompute the compaction calibration estimate at calibration
  time, not request-build time, so a reactive-compaction reissue that
  shrinks the request feeds the EMA the sample that actually completed.
- errhint: match bare HTTP status codes (401/403/429/529) only at digit
  boundaries, so an incidental number ('4290ms', 'id 14015') isn't
  mis-bucketed as auth/rate-limit.
- bash: drop the stray unmatched ']' in the output-truncation marker.
- tui /retry: apply the same exiting/compactInFlight guards commandPrompt
  has, so a retry can't race compactResultMsg's rewrite of session state.
- tui /export: write the transcript 0o600 (it may contain echoed
  secrets), not world/group-readable 0o644.

* test(tui): skip export file-mode assertion on Windows

os.WriteFile ignores Unix permission bits on Windows and Stat reports
0666, so the 0o600 assertion failed there. The production 0o600 is
honored on Linux/macOS (where it matters); guard the check with
runtime.GOOS so Windows smoke passes.

* fix(tui): align /resume latest filters + case-insensitive workspace match on Windows

Address CodeRabbit review on #21:
- latestResumableInWorkspace now applies the SAME filters as the picker
  (skip zero-event metadata and empty/failed runs), so /resume latest
  can't land on a session the picker intentionally hides.
- sessionMatchesWorkspace compares paths case-insensitively on Windows
  (its filesystem is), so the same workspace spelled with different casing
  no longer hides resumable sessions; other platforms stay case-sensitive.

* fix(tui): /retry resends the last prompt's image/PDF attachments

/retry relaunched only lastPrompt's text, but launchPrompt clears the
pending image/document queues once a turn is sent. A vision- or PDF-backed
prompt that failed would silently retry as text-only and answer a different
task. Snapshot the consumed attachments at launch and re-stage them on
/retry so launchPrompt rebuilds an identical request (document preamble,
images, and the submit-time vision re-check). startNewSession drops the
snapshot so a post-/new /retry cannot leak the previous session's
attachments. Also correct the OAuthResolver factory comment, which omitted
the Gemini provider it is now passed to.

* fix: address jatmn review (reconnect 5xx, /edit attachments, edit-cap, coalescer test)

- reconnect: exclude HTTP 5xx (500/502/503/504) before the transport
  substring match so "504 Gateway Timeout" no longer slips through on the
  generic "timeout" needle. A gateway timeout is non-idempotent — the
  completion POST may already have reached the model — so replaying the
  connect risked duplicate billable work. Reuses a digit-boundary status
  matcher exported from errhint (HasStatusCode). Test asserts 504/500 stay
  non-reconnectable.

- tui /edit: re-stage the remembered image/PDF snapshot alongside the
  recalled prompt text, mirroring the /retry fix — editing a vision- or
  document-backed prompt no longer silently resends a text-only version.

- compaction recent-edits: order edits by LAST edit, not first, and move a
  re-touched path to the newest position on merge, so the maxRecentEdits
  tail cap keeps the file the model most recently edited instead of dropping
  it. Adds mergeRecentEdits (edit-specific) and a capped-list regression.

- coalescer test: inject the frame timer via afterFunc so
  TestCoalescerBatchesDeltas proves buffering deterministically instead of
  racing the real 16ms wall-clock timer.

* fix(tools): bound bash output capture in memory, not just model-visible text

Previously stdout/stderr streamed into unbounded bytes.Buffers and were only
truncated to the 96 KiB budget AFTER the command finished, so a runaway command
(cat huge.log, yes) could grow Zero's memory until it stalled or OOMed before
budgetBashOutput ever ran. Replace the capture with boundedBuffer, an io.Writer
that keeps only the head+tail each stream will surface and discards the middle as
it arrives, while counting the true total for the truncation marker. Memory is now
bounded to ~head+2×tail per stream regardless of output size.

truncateHeadTail is refactored onto a total-aware core so the string path (and its
tests) are unchanged, and budgetBashCapture reports the true raw_bytes even though
only a bounded slice was held. Adds boundedBuffer/capture unit tests and an
end-to-end test that streams ~5× the budget and asserts bounded emission with an
accurate raw_bytes.
2026-07-05 13:15:14 +05:30

559 lines
20 KiB
Go

package tui
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestModelSupportsVisionTUI(t *testing.T) {
cases := []struct {
modelName string
want bool
}{
{modelName: "gpt-4.1", want: true}, // vision model in the catalog
{modelName: "claude-sonnet-4.5", want: true}, // vision model in the catalog
{modelName: "claude-haiku-3.5", want: false}, // claude-3-5-haiku has no image input support
{modelName: "totally-unknown-custom", want: false}, // not in catalog -> can't confirm
{modelName: "", want: false},
}
for _, tc := range cases {
m := newModel(t.Context(), Options{ModelName: tc.modelName})
got := m.modelSupportsVisionTUI()
if got != tc.want {
t.Fatalf("modelSupportsVisionTUI(%q) = %v, want %v", tc.modelName, got, tc.want)
}
}
}
func writeTestPNG(t *testing.T, dir, name string) string {
t.Helper()
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,
}
path := filepath.Join(dir, name)
if err := os.WriteFile(path, png, 0o644); err != nil {
t.Fatalf("write png: %v", err)
}
return path
}
func lastTranscriptText(m model) string {
if len(m.transcript) == 0 {
return ""
}
return m.transcript[len(m.transcript)-1].text
}
func TestImageCommandAttachRendersChip(t *testing.T) {
root := t.TempDir()
writeTestPNG(t, root, "photo.png")
m := newModel(context.Background(), Options{Cwd: root, ModelName: "gpt-4.1"})
m.input.SetValue("/image photo.png")
updated, _ := m.handleSubmit()
next := updated.(model)
if len(next.pendingImages) != 1 {
t.Fatalf("expected 1 pending image, got %d", len(next.pendingImages))
}
if next.pendingImages[0].MediaType != "image/png" {
t.Fatalf("MediaType = %q, want image/png", next.pendingImages[0].MediaType)
}
if len(next.pendingImageLabels) != 1 || next.pendingImageLabels[0] != "photo.png" {
t.Fatalf("labels = %v, want [photo.png]", next.pendingImageLabels)
}
if chips := renderImageChips(next.pendingImageLabels); chips == "" {
t.Fatal("expected a chip row for pending images")
} else if !strings.Contains(chips, "[Image #1]") || strings.Contains(chips, "photo.png") {
t.Fatalf("chip row %q should be the compact numbered chip, not the file name", chips)
}
}
func TestImageCommandClear(t *testing.T) {
root := t.TempDir()
writeTestPNG(t, root, "photo.png")
m := newModel(context.Background(), Options{Cwd: root, ModelName: "gpt-4.1"})
m.input.SetValue("/image photo.png")
updated, _ := m.handleSubmit()
m = updated.(model)
m.input.SetValue("/image clear")
updated, _ = m.handleSubmit()
next := updated.(model)
if len(next.pendingImages) != 0 || len(next.pendingImageLabels) != 0 {
t.Fatalf("expected cleared pending images, got %d/%d", len(next.pendingImages), len(next.pendingImageLabels))
}
}
func TestImageCommandNonVisionRefuses(t *testing.T) {
root := t.TempDir()
writeTestPNG(t, root, "photo.png")
// A custom/unknown model id is treated as non-vision (can't confirm).
m := newModel(context.Background(), Options{Cwd: root, ModelName: "totally-unknown-custom"})
m.input.SetValue("/image photo.png")
updated, _ := m.handleSubmit()
next := updated.(model)
if len(next.pendingImages) != 0 {
t.Fatalf("non-vision model must refuse: got %d pending images", len(next.pendingImages))
}
notice := lastTranscriptText(next)
if !strings.Contains(notice, "does not support image input") {
t.Fatalf("expected an inline refusal notice, got %q", notice)
}
}
func TestImageCommandMissingFileNotice(t *testing.T) {
root := t.TempDir()
m := newModel(context.Background(), Options{Cwd: root, ModelName: "gpt-4.1"})
m.input.SetValue("/image nope.png")
updated, _ := m.handleSubmit()
next := updated.(model)
if len(next.pendingImages) != 0 {
t.Fatal("a missing file must not attach")
}
if notice := lastTranscriptText(next); !strings.Contains(notice, "nope.png") {
t.Fatalf("expected a notice naming the missing file, got %q", notice)
}
}
func TestTranscriptViewShowsImageChips(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4.1"})
m.width = 100
m.height = 30
m.pendingImageLabels = []string{"photo.png", "diagram.gif"}
view := m.transcriptView()
if !strings.Contains(view, "[Image #1]") || !strings.Contains(view, "[Image #2]") {
t.Fatalf("transcript view should show numbered image chips, got:\n%s", view)
}
}
func TestSubmitThreadsImagesThenClears(t *testing.T) {
root := t.TempDir()
writeTestPNG(t, root, "photo.png")
captured := make(chan []zeroruntime.ImageBlock, 1)
provider := &fakeProvider{events: []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventText, Content: "ok"},
{Type: zeroruntime.StreamEventDone},
}}
m := newModel(context.Background(), Options{
Cwd: root,
ProviderName: "openai",
ModelName: "gpt-4.1",
Provider: provider,
Registry: tools.NewRegistry(),
SessionStore: testSessionStore(t),
})
// Capture the Images the agent run is launched with.
m.agentOptions.OnText = func(string) {}
m.captureRunImages = func(imgs []zeroruntime.ImageBlock) { captured <- imgs }
m.input.SetValue("/image photo.png")
updated, _ := m.handleSubmit()
m = updated.(model)
if len(m.pendingImages) != 1 {
t.Fatalf("setup: expected 1 staged image, got %d", len(m.pendingImages))
}
m.input.SetValue("describe this")
updated, cmd := m.handleSubmit()
next := updated.(model)
if cmd == nil {
t.Fatal("expected a prompt submit to start a run")
}
if len(next.pendingImages) != 0 || len(next.pendingImageLabels) != 0 {
t.Fatalf("submit must clear pending images, got %d/%d", len(next.pendingImages), len(next.pendingImageLabels))
}
execCmd(cmd) // run the agent goroutine; it invokes captureRunImages
select {
case imgs := <-captured:
if len(imgs) != 1 || imgs[0].MediaType != "image/png" {
t.Fatalf("agent run should receive 1 png image, got %#v", imgs)
}
default:
t.Fatal("expected captureRunImages to be invoked with the staged image")
}
}
// TestSubmitDropsImagesWhenModelSwitchedToNonVision attaches an image on a vision
// model, then simulates a /model switch to a non-vision id before submit. The
// submit-time re-check must drop the images (the agent run receives none), append
// an inline notice mirroring exec's drop+warn wording, and still clear pending
// state.
func TestSubmitDropsImagesWhenModelSwitchedToNonVision(t *testing.T) {
root := t.TempDir()
writeTestPNG(t, root, "photo.png")
captured := make(chan []zeroruntime.ImageBlock, 1)
provider := &fakeProvider{events: []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventText, Content: "ok"},
{Type: zeroruntime.StreamEventDone},
}}
m := newModel(context.Background(), Options{
Cwd: root,
ProviderName: "openai",
ModelName: "gpt-4.1",
Provider: provider,
Registry: tools.NewRegistry(),
SessionStore: testSessionStore(t),
})
m.agentOptions.OnText = func(string) {}
m.captureRunImages = func(imgs []zeroruntime.ImageBlock) { captured <- imgs }
// Attach on the vision model.
m.input.SetValue("/image photo.png")
updated, _ := m.handleSubmit()
m = updated.(model)
if len(m.pendingImages) != 1 {
t.Fatalf("setup: expected 1 staged image, got %d", len(m.pendingImages))
}
// Simulate a /model switch to a non-vision (catalog-unknown) model.
m.modelName = "totally-unknown-custom"
m.input.SetValue("describe this")
updated, cmd := m.handleSubmit()
next := updated.(model)
if cmd == nil {
t.Fatal("expected a prompt submit to start a run")
}
if len(next.pendingImages) != 0 || len(next.pendingImageLabels) != 0 {
t.Fatalf("submit must clear pending images, got %d/%d", len(next.pendingImages), len(next.pendingImageLabels))
}
if notice := lastTranscriptText(next); !strings.Contains(notice, "does not support image input") {
t.Fatalf("expected an inline drop notice, got %q", notice)
}
execCmd(cmd) // run the agent goroutine; it invokes captureRunImages
select {
case imgs := <-captured:
if len(imgs) != 0 {
t.Fatalf("non-vision model must receive no images, got %#v", imgs)
}
default:
t.Fatal("expected captureRunImages to be invoked (with no images)")
}
}
// writeTestPDF writes a tiny single-page PDF whose text layer is the given
// string and returns its path. It mirrors the in-package fixture builder so the
// TUI test exercises the real LoadDocument path on real PDF bytes.
func writeTestPDF(t *testing.T, dir, name, text string) string {
t.Helper()
var buf bytes.Buffer
offsets := make([]int, 0, 8)
startObj := func() { offsets = append(offsets, buf.Len()) }
buf.WriteString("%PDF-1.4\n")
startObj()
buf.WriteString("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n")
startObj()
buf.WriteString("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n")
startObj()
buf.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n")
content := "BT /F1 24 Tf 72 700 Td (" + text + ") Tj ET"
startObj()
buf.WriteString("4 0 obj\n<< /Length " + strconv.Itoa(len(content)) + " >>\nstream\n")
buf.WriteString(content)
buf.WriteString("\nendstream\nendobj\n")
startObj()
buf.WriteString("5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n")
xrefStart := buf.Len()
buf.WriteString("xref\n0 " + strconv.Itoa(len(offsets)+1) + "\n0000000000 65535 f \n")
for _, off := range offsets {
buf.WriteString(fmt.Sprintf("%010d 00000 n \n", off))
}
buf.WriteString("trailer\n<< /Size " + strconv.Itoa(len(offsets)+1) + " /Root 1 0 R >>\nstartxref\n" + strconv.Itoa(xrefStart) + "\n%%EOF\n")
path := filepath.Join(dir, name)
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
t.Fatalf("write pdf: %v", err)
}
return path
}
// A PDF carries a text layer every model can read, so /image <file.pdf> stages a
// pending document even on a non-vision model -- unlike a raw image, which is
// refused. No page images are staged without a rasterizer.
func TestImageCommandAttachesPDFTextOnNonVisionModel(t *testing.T) {
root := t.TempDir()
writeTestPDF(t, root, "spec.pdf", "Design spec body text")
m := newModel(context.Background(), Options{Cwd: root, ModelName: "totally-unknown-custom"})
m.input.SetValue("/image spec.pdf")
updated, _ := m.handleSubmit()
next := updated.(model)
if len(next.pendingDocuments) != 1 {
t.Fatalf("expected 1 pending document, got %d", len(next.pendingDocuments))
}
if next.pendingDocuments[0].label != "spec.pdf" {
t.Fatalf("document label = %q, want spec.pdf", next.pendingDocuments[0].label)
}
if !strings.Contains(next.pendingDocuments[0].text, "Design spec body text") {
t.Fatalf("document text %q should contain the body", next.pendingDocuments[0].text)
}
if len(next.pendingImages) != 0 {
t.Fatalf("no rasterizer: expected 0 page images, got %d", len(next.pendingImages))
}
// A successful attach is silent now (the [Doc #N] composer chip is the
// confirmation); the pending-document assertions above verify it.
}
// A real PDF whose path lacks a ".pdf" extension is still routed to the document
// path by a content sniff (not the extension), so its text layer attaches even on
// a non-vision model instead of being refused as a non-image.
func TestImageCommandAttachesExtensionlessPDFByContent(t *testing.T) {
root := t.TempDir()
writeTestPDF(t, root, "spec", "Extensionless PDF body text")
m := newModel(context.Background(), Options{Cwd: root, ModelName: "totally-unknown-custom"})
m.input.SetValue("/image spec")
updated, _ := m.handleSubmit()
next := updated.(model)
if len(next.pendingDocuments) != 1 {
t.Fatalf("expected 1 pending document from a content-sniffed PDF, got %d", len(next.pendingDocuments))
}
if !strings.Contains(next.pendingDocuments[0].text, "Extensionless PDF body text") {
t.Fatalf("document text %q should contain the body", next.pendingDocuments[0].text)
}
if notice := lastTranscriptText(next); strings.Contains(notice, "does not support image input") {
t.Fatalf("a real PDF must not be refused at the vision gate, got %q", notice)
}
}
// A .pdf-named file that is not a real PDF is rejected with the explicit
// not-a-PDF notice and stages nothing.
func TestImageCommandRejectsFakePDF(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "fake.pdf"), []byte("definitely not a pdf"), 0o644); err != nil {
t.Fatalf("write fake: %v", err)
}
m := newModel(context.Background(), Options{Cwd: root, ModelName: "gpt-4.1"})
m.input.SetValue("/image fake.pdf")
updated, _ := m.handleSubmit()
next := updated.(model)
if len(next.pendingDocuments) != 0 || len(next.pendingImages) != 0 {
t.Fatal("a fake PDF must stage nothing")
}
if notice := lastTranscriptText(next); !strings.Contains(notice, "not a PDF") {
t.Fatalf("expected a not-a-PDF notice, got %q", notice)
}
}
// /image clear removes staged documents as well as images.
func TestImageCommandClearAlsoClearsDocuments(t *testing.T) {
root := t.TempDir()
writeTestPDF(t, root, "spec.pdf", "some text")
m := newModel(context.Background(), Options{Cwd: root, ModelName: "gpt-4.1"})
m.input.SetValue("/image spec.pdf")
updated, _ := m.handleSubmit()
m = updated.(model)
if len(m.pendingDocuments) != 1 {
t.Fatalf("setup: expected 1 staged document, got %d", len(m.pendingDocuments))
}
m.input.SetValue("/image clear")
updated, _ = m.handleSubmit()
next := updated.(model)
if len(next.pendingDocuments) != 0 {
t.Fatalf("clear must drop staged documents, got %d", len(next.pendingDocuments))
}
}
// The chip row shows a "[doc: …]" entry for staged documents.
func TestTranscriptViewShowsDocumentChips(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4.1"})
m.width = 100
m.height = 30
m.pendingDocuments = []pendingDocument{{label: "spec.pdf", text: "body"}}
view := m.transcriptView()
if !strings.Contains(view, "[Doc #1]") {
t.Fatalf("transcript view should show the document chip, got:\n%s", view)
}
}
// On submit, the staged document text is prepended to the prompt the agent
// receives (so the model can read it), and the pending documents are cleared.
func TestSubmitPrependsDocumentTextThenClears(t *testing.T) {
root := t.TempDir()
writeTestPDF(t, root, "spec.pdf", "Top secret design notes")
provider := &fakeProvider{events: []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventText, Content: "ok"},
{Type: zeroruntime.StreamEventDone},
}}
m := newModel(context.Background(), Options{
Cwd: root,
ProviderName: "openai",
ModelName: "gpt-4.1",
Provider: provider,
Registry: tools.NewRegistry(),
SessionStore: testSessionStore(t),
})
m.agentOptions.OnText = func(string) {}
m.input.SetValue("/image spec.pdf")
updated, _ := m.handleSubmit()
m = updated.(model)
if len(m.pendingDocuments) != 1 {
t.Fatalf("setup: expected 1 staged document, got %d", len(m.pendingDocuments))
}
m.input.SetValue("summarize the attached doc")
updated, cmd := m.handleSubmit()
next := updated.(model)
if cmd == nil {
t.Fatal("expected a prompt submit to start a run")
}
if len(next.pendingDocuments) != 0 {
t.Fatalf("submit must clear staged documents, got %d", len(next.pendingDocuments))
}
updated, _ = next.Update(execCmd(cmd))
_ = updated.(model)
if len(provider.requests) != 1 {
t.Fatalf("expected one provider request, got %d", len(provider.requests))
}
// The last message is the user turn; it must carry both the document text and
// the user's question.
msgs := provider.requests[0].Messages
last := msgs[len(msgs)-1]
if last.Role != zeroruntime.MessageRoleUser {
t.Fatalf("last message role = %v, want user", last.Role)
}
if !strings.Contains(last.Content, "Top secret design notes") {
t.Fatalf("agent prompt should contain the document text, got:\n%s", last.Content)
}
if !strings.Contains(last.Content, "summarize the attached doc") {
t.Fatalf("agent prompt should contain the user question, got:\n%s", last.Content)
}
if !strings.Contains(last.Content, "spec.pdf") {
t.Fatalf("agent prompt should name the attached document, got:\n%s", last.Content)
}
}
func TestRenderAttachmentChips(t *testing.T) {
if got := renderAttachmentChips(nil, nil); got != "" {
t.Fatalf("empty attachments should render no chips, got %q", got)
}
got := renderAttachmentChips([]string{"a.png", "b.png"}, []pendingDocument{{label: "spec.pdf"}})
if !strings.Contains(got, "[Image #1]") || !strings.Contains(got, "[Image #2]") {
t.Fatalf("chip row %q should include numbered images", got)
}
if !strings.Contains(got, "[Doc #1]") {
t.Fatalf("chip row %q should include the document", got)
}
// The long file name must NOT appear — compact numbered chips only.
if strings.Contains(got, "a.png") || strings.Contains(got, "spec.pdf") {
t.Fatalf("chip row %q should not show file names", got)
}
}
// TestRetryResendsAttachments guards the /retry contract: it must resend the exact
// request the last prompt carried — including its staged image and PDF document —
// not a degraded text-only prompt. launchPrompt clears the pending queues once a
// turn is sent, so without re-staging the remembered snapshot a vision/document-
// backed turn that failed would silently retry as a different task.
func TestRetryResendsAttachments(t *testing.T) {
root := t.TempDir()
captured := make(chan []zeroruntime.ImageBlock, 1)
provider := &fakeProvider{events: []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventText, Content: "ok"},
{Type: zeroruntime.StreamEventDone},
}}
m := newModel(context.Background(), Options{
Cwd: root,
ProviderName: "openai",
ModelName: "gpt-4.1",
Provider: provider,
Registry: tools.NewRegistry(),
SessionStore: testSessionStore(t),
})
m.agentOptions.OnText = func(string) {}
m.captureRunImages = func(imgs []zeroruntime.ImageBlock) { captured <- imgs }
// State left after a prior vision+PDF prompt was submitted: the pending queues
// are cleared, but the remembered snapshot survives so /retry can reproduce it.
m.lastPrompt = "describe both"
m.lastImages = []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte{0x89, 'P', 'N', 'G'}}}
m.lastImageLabels = []string{"photo.png"}
m.lastDocuments = []pendingDocument{{label: "spec.pdf", text: "Top secret design notes"}}
m.input.SetValue("/retry")
updated, cmd := m.handleSubmit()
next := updated.(model)
if cmd == nil {
t.Fatal("/retry with a remembered prompt should launch a run")
}
// The retried turn re-consumes and then clears the queues, exactly like a fresh
// submit; the snapshot must survive so a second /retry stays reproducible.
if len(next.pendingImages) != 0 || len(next.pendingImageLabels) != 0 || len(next.pendingDocuments) != 0 {
t.Fatalf("retry must clear pending queues after resend, got imgs=%d labels=%d docs=%d",
len(next.pendingImages), len(next.pendingImageLabels), len(next.pendingDocuments))
}
if len(next.lastImages) != 1 || len(next.lastDocuments) != 1 {
t.Fatalf("retry must keep the snapshot for a subsequent retry, got imgs=%d docs=%d",
len(next.lastImages), len(next.lastDocuments))
}
updated, _ = next.Update(execCmd(cmd))
_ = updated.(model)
select {
case imgs := <-captured:
if len(imgs) != 1 || imgs[0].MediaType != "image/png" {
t.Fatalf("retried run must resend the remembered image, got %#v", imgs)
}
default:
t.Fatal("expected the retried run to receive the remembered image")
}
if len(provider.requests) != 1 {
t.Fatalf("expected one provider request, got %d", len(provider.requests))
}
msgs := provider.requests[0].Messages
last := msgs[len(msgs)-1]
if !strings.Contains(last.Content, "Top secret design notes") {
t.Fatalf("retried prompt should re-prepend the document text, got:\n%s", last.Content)
}
if !strings.Contains(last.Content, "describe both") {
t.Fatalf("retried prompt should include the remembered user text, got:\n%s", last.Content)
}
}