Repository navigation
Description
Environment
| Item | Value |
|---|---|
| opencode | 2.1.4-x64 (desktop CLI opencode-cli.exe, Bun-compiled) |
| Platform | Windows (win32) |
| Provider | OpenAI-compatible Responses endpoint (open-responses protocol) |
| Model | openai/gpt-6-luna#max (any model reproduces — see below) |
| Config | media.image: { auto_resize: true, max_width: 540, max_height: 1212 } |
| Session shape | long UI-verification session, agent reads ~7 screenshots via the read tool |
Summary
When a session's history contains enough inline image data, LLM.compile throws
SchemaError: Expected a JSON-serializable value and the whole session goes to
outcome=failed. The session is not recoverable: every subsequent prompt fails
at the same point, because the offending data is still in the history. Users have to
edit their database to get the conversation back.
The request never reaches the network — this is a local encoding/validation failure,
so the provider and model are unrelated.
Stack trace (verbatim, from opencode.log)
cause="SchemaError: Expected a JSON-serializable value
at OpenAIResponses.fromRequest (B:/~BUN/root/chunk-m9c0z1h3.js:6:23866)
at OpenAIResponses.fromRequest (definition) (B:/~BUN/root/chunk-tw78hptr.js:2:5770)
at LLM.compile (B:/~BUN/root/chunk-m9c0z1h3.js:6:24210)
at LLM.compile (definition) (B:/~BUN/root/chunk-m9c0z1h3.js:6:23788)
at SessionStep.attempt (B:/~BUN/root/chunk-6hz4z8ma.js:366:3581)
at SessionRunner.runStep (B:/~BUN/root/chunk-6hz4z8ma.js:366:2653)
at SessionRunner.drain (B:/~BUN/root/chunk-2j9p1jnx.js:18:2552)"
Relevant shipped code (recovered from the bundle, minified):
// LLM.compile: body.from() runs first; prepareTransport / streamPrepared come after,
// so nothing is sent when the failure above is raised
Rr = c("LLM.compile")(function*(e, r) {
let a = vt(e), i = a.model.route,
l = yield* i.body.from(a), // <-- throw site
f = yield* i.prepareTransport(l, a, r);
return { request: a, route: i, body: l, prepared: f }
})
// the image part entering the request body
let h = y_(e.media),
x = h ?? (yield* zc(i.name, e.media)).dataUrl;
return { type: "input_image", image_url: x, detail: p }
// input_image schema: image_url must be a string
Dq = o({ type: z("input_image"), image_url: t, detail: n(t) })The message text Expected a JSON-serializable value exists in the binary only inside
Effect Schema's expected-type description table (next to acyclic value, an Option,
a Result, …), i.e. the request body is being encoded against a JSON value schema and
one member is not JSON-representable.
Reproduction
A. Accumulation (what users hit in practice)
media.image.max_width/max_height = 540/1212.- In one session, have the agent
reada screenshot (~540×1212 PNG, ~150 KB of base64)
seven times, interleaved with normal tool calls. - Send any further message. The step fails with the error above.
B. Single large image
- Same config, but read one image whose inline base64 exceeds ~1.3 MB.
- Next step fails the same way.
Measured boundary (empirical, ≥14 controlled runs)
base64 = total inline image payload present in the compiled request.
| total inline base64 | images | result |
|---|---|---|
| 8 KB · 452 KB · 531 KB · 909 KB | 1 | pass |
| 888 KB | 5 | pass |
| 933 KB · 1063 KB | 2 | pass |
| 1179 KB | 1 | pass (outlier) |
| 1077 KB · 1206 KB · 1366 KB | 4 · 6 · 1 | fail |
| 1437 KB · 1548 KB · 1793 KB | 3 · 3 · 7 | fail |
| 1905 KB · 1965 KB · 2922 KB | 1 · 1 · 2 | fail |
No single-image size, pixel dimension, or total-payload threshold fits every row —
notably 1179 KB passed while 1077 KB failed. Failures cluster at ≳1 MB of inline media
per request, which is consistent with a request-time lowering step
(the bundle contains lowerMedia, oversized_image: "downsize" | "error",
transformations, media_type) that is not deterministic about producing a
JSON-safe replacement.
Also ruled out by the same matrix:
- pixel dimensions alone — an 891×2000 image of 8 KB (same dims as crashing
screenshots) passes; - payload size in general — a 500 KB text tool result passes;
- a per-image byte cap — a single 1179 KB image passes.
Same-session control (this is the strongest evidence)
One session, one model, identical text, only the stored image bytes changed:
| state | history images | result |
|---|---|---|
| before | 3 × 891×2000 PNG (487+487+463 KB base64) | fails |
| after | same 3 images re-encoded smaller (203+203+190 KB base64) | passes, full turn completes |
The offending value is therefore derived from the inline image data, consistent with
the throw site inside OpenAIResponses.fromRequest and the image_url field.
What is proven / what is not
Proven:
- failure is local, inside
LLM.compile→OpenAIResponses.fromRequest, before transport; - it is an encoding/validation failure (
SchemaError), independent of provider and model; - it is triggered by inline image data (same-session control above);
- it permanently kills the session and survives restarts;
- the field it is about is
input_image.image_url(the only image-derived member of the
request body, and the schema requires a string there).
Not proven (stated honestly):
- the concrete runtime type of the offending value. A V8 heap snapshot taken right after
the failure showed all 27image_urledges pointing at strings (the 2 closure-valued
ones are code templates insideJSModuleEnvironment, not request data), and the
response.createrequest strings were truncated by V8 at 1 KB — i.e. the bad object
had already been collected.--log-level tracedoes not add any structure to the error.
Suggested fixes (three independent defects)
- Surface the path. The error carries no field path; log
ParseIssue.path/
the offending value's type in the cause chain. As-is this is undebuggable for users. - Degrade instead of throw. The image pipeline already has a graceful path
(Image.SizeError→[n image omitted: …]placeholder, and
oversized_image: "downsize" | "error"). The request-time lowering path must
never produce a non-JSON member; fall back to the placeholder when it cannot. - Do not kill the session. An assembly failure should fail the step with a
retryable/explainable error (and ideally offercompact/ "drop images" recovery),
not leave the session permanently dead on history that cannot be changed.
Workaround (user side)
Downscale or re-encode stored history images (PNG → JPEG keeps pixels and costs ~1/5
the bytes), keeping the per-request inline total well under ~1 MB:
# png -> jpeg q88, same pixel dimensions; also rewrite "mime":"image/png" -> image/jpeg
# observed: 540x1212 PNG 614 KB base64 -> 540x1212 JPEG q88 78 KB base64
Activity
opencode-agent commentedon Oct 25, 2026
Thanks for the detailed write-up. I tried to reproduce this on 2.1.4 (Linux,
opencode run --standalone) with yourmedia.imagesettings (auto_resize: true, 540×1212 max), using a local mock of the Responses API:reading seven 891×2000 PNGs (0.4–2.8 MB each, so they get resized) in one session, reaching about 7.4 MB per request, then sending a text-only prompt.openaiprovider pointed at the mock, which uses the WebSocket (response.create) transport.Every turn completed, and
SchemaError: Expected a JSON-serializable valuenever appeared, so it seems to depend on something in your setup that I didn't match. These would help:opencode.json(with secrets removed), especially the provider entry: isopenaithe built-in provider with a custombaseURL, a custom provider with its ownpackage, or a ChatGPT (OAuth) login? Anysettings.transportorsettings.encoding?opencode.log, including the lines just before the error (provider, transport, request size if shown).@opencode/cli), or only with the desktop app's bundledopencode-cli.exeon Windows.Related reports where images left a session unusable, though those were errors from the provider rather than this local one: #21668, #47487, #54690.
HX-Wrdzgzs commentedon Oct 25, 2026
Thanks — the non-repro is actually the most useful data point in this thread. I went back and reduced it to a minimal case, and the trigger is not a size threshold: it is non-deterministic.
1. Your four questions
Provider config.
openaiis the built-in provider (there is noopenaientry underproviders), nosettings.transport, nosettings.encoding. Redacted:{ "default_agent": "Team", "model": "openai/gpt-6-luna#max", "media": { "image": { "auto_resize": true, "max_width": 540, "max_height": 1212 } }, "providers": { // two unrelated custom providers (package "@opencode/ai/providers/openai-compatible"), // neither serves an "openai/*" model, neither is used by the failing sessions } }Log lines around the failure. At
infothere is no provider / transport / request-size line at all — the only record is the whole cause on a single line:--log-level traceadds nothing structural to it.npm CLI. Tested — same crash, also non-deterministic (see §3). So this is not specific to the desktop bundle.
Minimal reproduction. §2.
2. Minimal reproduction: fresh session, ONE image, ONE turn
The
readresult puts the image in history; the next compile of the request body fails.media.image.auto_resizedoes work — the stored image is 540x1212, confirmed in the session DB — but it is re-encoded as PNG, so a dense screenshot stays 2.6–3.4 MB of base64 and there is no JPEG/quality knob.3. Same input, different outcome
Desktop 2.1.4 (
opencode-cli.exe, Windows), built-inopenai,openai/gpt-6-luna#max, one image per fresh session, one repetition each:Repeating the same file, same command, same config:
And with the npm CLI,
npx -y @opencode/[email protected] run --standalone, same 3410 KB image:Bigger images fail more often, but the same bytes sometimes succeed and sometimes kill the session — which is exactly why an 7.4 MB run on a fast machine can look clean.
4. What this rules out
maxBase64Bytesas a guard: setting it (tried bothmax_base64_bytesandmaxBase64Bytes, e.g.1500000) changes nothing — the 2944 KB image is still stored unchanged and still crashes/passes at random. Note the default is5242880, so the image never reaches that limit and noexceeding configured limitserror is ever raised.5. Where I would look
From the bundle, the image service is built around a lazily imported module:
and the part is assembled as
So a non-string / non-JSON
image_urlcan only come from that resolution not having settled — a race between two normalizations of the same media item, or between the lazy module's initialisation and its first use, would fit every observation above. I could not pin the concrete value: a V8 heap snapshot taken right after the failure showed allimage_urledges already pointing at strings (the bad object had been collected), and the error carries no path.Cheap things worth trying: memoize/await one normalize promise per media item before building the part, and assert
typeof image_url === "string"with the existing[n image omitted: …]placeholder as fallback.6. Still worth fixing regardless of the root cause
--log-level tracedoes not help).Image.SizeError→[n image omitted]path exists but this path never reaches it.Happy to run anything you want: instrumented build, traces, extra seeds/iterations, or a specific branch.
HX-Wrdzgzs commentedon Oct 25, 2026
Correction to my previous comment — the "non-deterministic race" conclusion in §3 does not hold. I re-ran everything with verification (each run checked against the session store, so a "pass" can no longer be a run where the
readnever happened) and the picture is different.Verified runs, desktop 2.1.4, built-in
openai,openai/gpt-6-luna#max, every image present in the session at the stated size:And retrying a session that had just crashed, same history, text-only prompt: 10/10 crashed again (2 retries on each of the 5 sessions that crashed).
So, correcting myself:
I could not produce a clean minimal discriminator, and I'd rather say that than invent a mechanism. A large text-only payload cannot be tested this way:
readtruncates the 3.6 MB file to 4 KB in the session, so it never reaches the request body (and that run passed).Next step I'd take, if it helps you: run the four cases above through a mock endpoint (like yours) and capture the outgoing body, so sizes and item ordering are measured rather than inferred from the session store. If you already have that harness, this should settle it in one pass.
Sorry for the noise — the numbers in my previous comment came from runs where I checked whether it crashed but not whether the read actually happened.
Te-River commentedon Oct 25, 2026
We hit the same error on 2.1.4 and the stack points somewhere slightly different, which may narrow the search.
Ours is the Chat Completions protocol, not Responses:
Same bundle position as your
OpenAIResponses.fromRequestframe, so it is one shared code path rather than something specific toinput_image.That matters because the failure is not
image_urlrejecting a non-string. The encoder isencodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)), and its only failure mode isJSON.stringifyreturningundefinedor throwing — i.e. the value isundefined/a function/a symbol, or the object is circular or contains aBigInt.stringifyJsonmaps all of those toSchemaIssue.InvalidValue({ expected: "a JSON-serializable value" }).So the question is which value inside
fromRequestis being stringified, not which provider field it lands in. In the Chat path there are two calls that serialize history: the tool-callarguments(openai-chat.ts:371,encodeJson(part.input ?? {})) and the tool result text (shared.tstoolResultText). Both run before any image lowering.On the image correlation: if the offending value is produced while lowering media, our log would still show
fromRequest, because that is where the lowering runs. So your same-session control is still consistent with this — I am not disputing it, just noting the throw site does not requireimage_urlitself to be bad.Happy to share the session that reproduces it here (2.1.4, Windows) if that helps, once we have narrowed which value it is.
HX-Wrdzgzs commentedon Oct 25, 2026
Second correction, and this one is measured instead of inferred. I built the mock endpoint you suggested and captured the actual outgoing bodies.
Setup: local mock serving
POST /v1/responses; the built-inopenaiprovider re-pointed at it through project config (providers.openai.settings.baseURL) — that works, and the body is genuinely Responses format (input/model/instructions/tools/include/reasoning/store/stream/text, nomessages). Every request logged with its exact byte size and parsed structure.What the bodies show
input_imagemessage item. They arrive asfunction_call_output.output[1].image_url— i.e. inside thereadtool result.okreturned.LLM.compile, before transport).The replay boundary (deterministic)
Single image per fresh session: run 1 =
readit, run 2 = a text-only prompt (so run 2's request must replay the stored image).Plus: retrying a session after it died crashed again 10/10. And
"plugins": []in the project config did not change anything.So the trigger is materialising stored image bytes into a replayed request, not the size of the request as such: a 3.07 MB body with a freshly-produced image is fine, while a 1.13 MB body replaying that same image is not. The boundary sits between 1,085,168 and 1,126,972 characters of replayed inline media on this machine (Windows, 2.1.4, both the desktop bundle and
npx @opencode/[email protected]).A question back about your 7.4 MB run
That result still does not fit: our replay boundary is ~1.1 MB of replayed image characters, yet your final text-only prompt "reached about 7.4 MB per request" and completed.
Could you check what the mock actually received on that final prompt — specifically the total characters of
image_urlinside the replayedfunction_call_outputitems? If it is not close to 7 MB there, the test did not exercise this path (e.g. the images were compacted away, dropped, or never made it into the replayed body), and both results would be consistent. Our per-request numbers above are all post-compaction-free, straight from the wire.Minimal reproduction (two turns)
dense.png= 891×2000 RGBA PNG, ~89 % incompressible pixels (generator in my earlier comment).auto_resizeshrinks it to 540x1212 and stores ~1.13 MB of base64, which is enough to cross the replay boundary.The three fixes from my first comment still stand, and this data sharpens them: the failure is in the history→request path for
function_call_outputmedia, so logging the offending path/type there, falling back to[n image omitted: …], and failing the step instead of the session would each turn this into a usable error rather than a dead conversation.HX-Wrdzgzs commentedon Oct 25, 2026
That is a very useful narrowing — thanks.
OpenAIChat.fromRequestat the same bundle column as ourOpenAIResponses.fromRequest(chunk-m9c0z1h3.js:6:23866) settles that this is one shared encoder, not an image-field type check. It also matches what I see here: identical crash with the desktop bundle and withnpx @opencode/[email protected], and it fires before any socket is opened.If the failure mode is
JSON.stringify→undefined/throw, then this may help: the value only goes bad on the replay path.Measured with a local mock that logs the exact body:
and the replay boundary is sharp and deterministic (fresh session, one image, second turn is a text-only prompt):
Retrying a session that just failed: 10/10 failed again.
"plugins": []changes nothing. The body image characters are byte-for-byte identical to what is stored in the session (3,016,900 = 3,016,900), so nothing is being duplicated or rewritten on the way out.So the bad value is produced while re-materialising a stored tool result, and only once that result is large enough — which lines up with your
toolResultTextlead: if the stored result is turned back into a media/file part and the text extractor yieldsundefinedfor it,encodeJson(undefined)hits exactly yourInvalidValue({ expected: "a JSON-serializable value" }). Note the same text result at 3 MB on the first pass is fine, which is what you'd expect if the ingest path still has the string in hand and only the replay path has to rebuild the part.Yes please — post the reproducing session. If it is Chat Completions, having one failing session on each protocol with the same shared encoder is probably enough to pin the exact call.
Te-River commentedon Oct 25, 2026
We reproduced this on 2.1.4 and found the concrete mechanism, which is a bit different from where the stack trace points.
Our crash is on the Chat Completions protocol (
OpenAIChat.fromRequest), at the same bundle position as yourOpenAIResponses.fromRequestframe. So this is one shared lowering path, not something specific toinput_image.What actually throws.
encodeJsonisSchema.encodeSync(Schema.fromJsonString(Schema.Unknown)), and its encode getter isJSON.stringify. The only way it producesExpected a JSON-serializable valueisJSON.stringifyreturningundefinedor throwing — i.e. the value isundefined/a function/a symbol, or the object is circular or holds aBigInt. It is not a field-shape check, soimage_urlbeing a string does not rule it out.The value. A tool result of the form
{ type: "json", value: undefined }:That reproduces locally: the same part with
value: nullreturns"null". The part is constructible becauseToolResultValue.make(value, "json")(ai/src/schema/messages.ts) returns{ type, value }without guardingundefined; one producer iscore/src/aisdk.ts,ToolResultValue.make(event.result, ...)whereevent.resultcan beundefined.It is not bad history: decoding a persisted part requires
value(Missing key at ["result"]["value"]), so this is an in-memory value, not something a rebuild can repair. That may be why editing the database does not help in some reports.Why it kills the session.
Schema.encodeSyncthrows aParseError, which is a defect, not anAIError.session/step.tscaptures withEffect.exitand readsExit.findErrorOption, which only looks at the typed channel, sollmFailurestaysundefined, and the retry/toSessionErrorpaths are skipped.step.tsthen re-raises the cause, which escapesSessionRunner.drain— the drain fiber dies, and every later prompt fails at the same point.Fix on a fork branch (
json-tool-result, three commits plus one follow-up): normalizeundefinedtonullinToolResultValue.makeso the invalid state cannot be constructed; make the json branch oftoolResultTexttotal by using the existingSchema.encodeUnknownOptionencoder with a text fallback; and wraproute.body.frominEffect.catchCauseso a lowering defect becomes a route-scoped typedAIError(interrupts and typed failures pass through unchanged). With that, the step fails with a readable error instead of killing the session.packages/aiis 1924 pass, and the lowering-defect case fails ondevand passes here.HX-Wrdzgzs commentedon Oct 25, 2026
That is a solid mechanism, and I believe it explains your reproduction. It does not yet explain ours, and there are two concrete divergences worth checking before this is closed as one bug.
1. Our sessions contain zero
json-typed parts. I scanned every message of the four sessions that died here (11 failed drains total): tool results are onlytext+fileparts — no{"type":"json"}anywhere in the store, and no message containing such a part. So there is nothing for the json branch oftoolResultTextto encode in our case.2. Editing the database does fix ours. You note the value is in-memory and a rebuild cannot repair it. Here the opposite holds: all four sessions were recovered by rewriting the stored image bytes only, and each was re-verified by continuing the conversation (
outcome=succeededafterwards, three times on the same session). So in our case the offending value is derived from persisted history, which rules out anevent.result === undefinedproducer for it.3. Ours is sharply size-dependent, on the replay path only. Measured with a mock that logs the real body, one image per fresh session, turn 1 =
read, turn 2 = text-only:Failing turns send zero bytes.
"plugins": []changes nothing, and the same happens onnpx @opencode/[email protected], so it is not the desktop bundle or a plugin.If the same
encodeJsonis involved, the value being stringified there must come from the media/file part of a replayed tool result rather than from ajsonpart — i.e. a second producer feeding the same encoder. The size dependence and the "replay only" asymmetry both point at whatever rebuilds the storedtype: "file"part ({uri: "data:image/png;base64,…", mime}) into the request.The decisive test: does your
json-tool-resultbranch fix the two-turn repro above? If it does, my analysis is wrong and this is one bug. If it does not, we have two defects sharing a symptom, and the lowering/rebuild path forfileparts needs the sameEffect.catchCausetreatment you added aroundroute.body.from— that part of your fix should help regardless, since it turns the defect into a readable step error instead of a dead drain fiber.I am happy to run any build you point me at; the harness is reproducible (
reada dense 891×2000 PNG, then send any text-only prompt).Te-River commentedon Oct 25, 2026
That replay measurement is the useful part, thanks — it splits the two turns, which nothing in the stack trace does.
One data point from the current
dev(not 2.1.4), built to your shape:OpenAICompatibleResponses(protocolopen-responses, the same one your config reaches), image carried by areadtool result, compiled through the realLLM.compilepath.The body reaches the transport with the image in
function_call_output, and nothing is dropped. So ondevthe replay turn does not reproduce at any of your boundary points, including above it — 3.0 MB compiles. That does not contradict your measurement; it just means whatever makes the second turn different from the first on 2.1.4 is not in the lowering path as it exists now, or it is gone.Given the failure is
JSON.stringifyreturningundefined, there are only three ways in: the value isundefined/a function/a symbol, the object is circular, or it holds aBigInt. A size threshold with a sharp step is consistent with a conversion that starts returning something else above a cutoff — so if you can capture the value at the cut (the 1,085,168 pass vs 1,126,972 fail pair is a very tight bracket), the type of the offending member would settle it immediately. AJSON.stringifyreplacer that records the firstundefined/cycle would do it without a heap snapshot.The fix on my fork (branch
json-tool-result) does not depend on identifying that value: an unencodable tool result now falls back to text instead of throwing,ToolResultValue.makerefuses to construct theundefinedcase, and any lowering defect becomes a route-scoped typed error instead of a defect that escapes the drain fiber — the step fails with a readable message and the session survives, which is your third defect.HX-Wrdzgzs commentedon Oct 25, 2026
Correction to my previous comment, with a sharper measurement. I said the bad value might come from a second producer somewhere else in the rebuild path. Two more runs narrow it a lot: the same image bytes are fine when they arrive as a user-message attachment, and fatal when they arrive inside a tool result.
Setup unchanged from my last comment: local mock logging the exact outgoing body,
read/attach a dense 891x2000 PNG,auto_resizeto 540x1212, turn 1 ingests, turn 2 is a text-only prompt (so it must replay stored media).readtool.input[].function_call_output.output[1].image_urlreadtool--fileattachment.input[0].content[1].image_url--fileattachment.input[0].content[1].image_urlSo:
input_imagevsimage_url, and not "the string is too long" — in both cases the body carries a plaindata:image/png;base64,...string, and the user-message slot survives 3.0 MB of it, replayed twice, with the body measured on the wire.That points straight at the
toolResultTextneighbourhood rather than at a separate media path — but the producer is not the json branch. In our four dead sessions the tool results aretype: "content"withtext+fileparts; there is no{"type":"json"}part anywhere in the store. So whatever callsencodeJsonthere is handed a value derived from a content/file tool result, not from a json result.Which makes your
5031bdf("close the remaining non-JSON tool result paths") the interesting commit — it is the one that touches the branch our data exercises. The decisive test is still the same and is now cheaper:and the control that must pass on any fixed build:
dense.png= 891x2000 RGBA PNG, ~89 % incompressible pixels (same generator as before). If a build makes the first pair pass while the second pair keeps passing, our case is fixed; if it only fixes the second, it never exercised our path.opencodejr commentedon Oct 28, 2026
Thanks for the detailed report and the measurements.
I tried to reproduce this on Linux with the same release (2.1.4) and your
media.imagesettings (auto_resize: true, 540×1212), going through the OpenAI Responses route to a local mock server. The agentreadseven large PNGs in one session, first at 540×1212 (about 1.2 MB base64 each), then at 891×2000 (about 3.5 MB base64 each). Every step went through, even with requests of 8–24 MB of inline images, and a follow-up prompt in the same session also worked. I never sawSchemaError: Expected a JSON-serializable value.That error text is what Effect reports when
JSON.stringifythrows while the request body is being built. Since the same version doesn't fail on Linux even far above your ~1 MB boundary, this looks specific to Windows (the Windows Bun runtime or the image handling there). I can't run Windows here, so it needs a maintainer who can. Two things would help:opencodeCLI from the npm package, not just the desktop app'sopencode-cli.exe?Related: #52042 (an image rejected by the provider leaves the session permanently failed) and #52448 (a binary MCP attachment breaks the session).
HX-Wrdzgzs commentedon Oct 28, 2026
Thanks for testing this again. Two of the requested items are already in this thread, so I want to consolidate them and clarify the specific path that fails.
I tested "npx -y @opencode/[email protected] run --standalone" on Windows, in addition to the bundled "opencode-cli.exe". This was reported in "my earlier reply" (#58934 (comment)) and confirmed in subsequent controlled tests. It is not specific to the desktop executable.
The Python/Pillow generator (seed "1042", 891×2000 RGBA, dense random pixels) is included in that earlier reply. No real screenshot or private data is needed.
My "latest controlled comparison" (#58934 (comment)) uses a local Responses mock and captures actual request bodies.
On my Windows 2.1.4 environment, the observed failure boundary for replayed tool-result media lies between 1,085,168 and 1,126,972 base64 characters.
The Linux tests are useful evidence of a platform or build difference, but they do not invalidate the Windows results.
Could a maintainer with access to Windows test the exact two-turn tool-result replay and compare it against the "--file" control?
It would be particularly useful to inspect the persisted tool-result parts and the mock server's received requests for the second turn.
Te-River also identified a separate "{ type: "json", value: undefined }" failure mechanism. However, my failing sessions contain only text/file tool-result parts and are recoverable by rewriting the persisted image bytes. I would therefore avoid treating that mechanism as a confirmed fix for this issue without testing the two-turn reproduction.
I'm happy to test an instrumented Windows build or candidate fix against the existing reproduction harness.
HX-Wrdzgzs commentedon Oct 29, 2026
Since the remaining split is platform, here is the image plus the exact Windows-side environment it comes from. If it also passes on Linux with this file, that is a useful result in itself — it would mean the trigger is not the bytes but the platform, and the image is then a control rather than a reproducer.
The image
https://gist.github.com/HX-Wrdzgzs/8d6937420330d3acaa551dd0005769ca
repro_dense.png— 1,915,429 bytes, sha256A7EA36526F10B60CCAC8E15E30CBC8097B96FEB7F983F137B89C109FCBEA0D19(18,18,20,255). No screenshots, no text, no private data — it is generated, and it exists only because uniform noise survives PNG compression, so after opencode's ownmedia.imagedownscale to 540×1212 the stored base64 is still ~1.17 M characters. Ordinary screenshots compress to a few hundred KB and never reach the boundary.The Windows side (this is the half that is missing from the Linux runs)
opencode-cli.exev2.1.4 (…\ai.opencode.desktop\cli\2.1.4-x64\opencode-cli.exe)npx -y @opencode/[email protected]— v2.1.4, node v24.18.0openai/gpt-6-luna#max(Responses)media.image { auto_resize: true, max_width: 540, max_height: 1212 }Both runtimes fail identically, so this is not the desktop wrapper.
Measured on this box (fresh session each, turn 1 =
readthe image, turn 2 = a text-only prompt so it must replay the stored tool result):Failing turns send zero bytes — the throw is in
LLM.compile, before transport. Retrying a session that just failed: 10/10 failed again."plugins": []changes nothing.And the slot asymmetry from my previous comment holds with the same file: the same image as a
--fileattachment replays fine at 1,222,122 and 3,016,306 characters (body 3,072,018 B measured), while through areadtool result it fails at 1,222,100. Both arrive as a plaindata:image/png;base64,…string in the body; only the container differs.So the summary of the split is: the bytes are not special, the size is not absolute, and both current
devand Linux tolerate far more. What is left is "Windows + replayed tool-result media".What would actually move this
Either a maintainer on Windows runs the two-turn repro, or you ship me an instrumented build and I run it here — I have the mock harness (captures the exact outgoing body and its byte count) and the boundary bisect script, and I can return whatever a
JSON.stringifyreplacer or a debug log prints. A build that only logs would still be enough: the failing turn sends nothing, so anything you print during compile is the only signal that exists.One aside, recorded but not claimed as a second defect: on this same Windows box and version, a text-only session (zero images) hit the same
SchemaErroron the Chat Completions route (OpenAIChat.fromRequest), one second after switching that session from a Responses model to a Chat model. I could not reproduce it in isolation, so I am not asserting a mechanism — I mention it only because it argues the failure is not confined to images here, which is consistent with the shared-encoder explanation and with "not the image bytes".HX-Wrdzgzs commentedon Oct 29, 2026
The replay failure is still present on 2.2.1 (the version that shipped today), and the boundary has moved, which may be a useful clue: something size-dependent changed, but nothing was fixed.
Same machine as before (Windows 11 for Workstations build 26300, x64), desktop bundle
opencode-cli.exev2.2.1, modelopenai/gpt-6.1-sol#max(Responses),media.image { auto_resize: true, 540x1212 }.Method is the same two-turn test as above: turn 1 =
reada synthetic dense PNG, turn 2 = a text-only prompt, so turn 2 must replay the stored tool result. Verdict is read from the session store (idle.outcomeplus the attached error), not from stdout:Expected a JSON-serializable valueSo on 2.1.4 the boundary was between 1.09 M and 1.13 M characters; on 2.2.1 it is somewhere between 1.73 M and 2.16 M. The failing turns still produce zero bytes on the wire — the throw is before transport — and turn 1 still succeeds at every size, including 3.0 M, exactly as on 2.1.4.
Two consequences:
JSON.stringifyreplacer recording the firstundefined/cycle. With 1.73 M as a known-good bracket and 2.16 M as a known-bad one, that instrumentation now has two clean inputs to diff.Also, a data point from normal use rather than a synthetic run: today on 2.2.1 a session with eight ordinary UI screenshots totalling 1,792,108 characters died the same way (
idle.outcome: failed, messageExpected a JSON-serializable value). Below the synthetic 2.16 M fail point and above the 1.73 M pass point — i.e. the real-world boundary sits inside the same bracket.I am happy to run any 2.2.x build you point me at, and I still have the mock harness that captures the exact outgoing body if you want per-turn byte counts on 2.2.1 as well.
HX-Wrdzgzs commentedon Oct 30, 2026
One correction to my own numbers above: total replayed characters is not the only variable. A real session just failed well below the single-image pass point I reported.
Two observations from the same machine, same route (
openaiResponses), samemedia.imageconfig:idle.outcome: failed/Expected a JSON-serializable value)So 1.73 M in one image is fine while 1.64 M spread over ten images is not. Whatever the size-dependent conversion is, it does not depend on the sum alone — count and/or per-image size participate. I should have tested that before quoting a character boundary, and the numbers I gave you in the previous comment need that qualification.
It also shows the practical shape of this bug: the failing session was a normal working conversation. It accumulated screenshots over a day — one 383 KB image, then three more totalling ~800 KB — and at that point every subsequent turn failed, including a bare "continue" prompt with no new content. Replaying the stored tool results is enough; nothing new has to be added.
Reducing that same session to 721,264 chars across the same ten images (each ≤ 98 KB after downscale/re-encode) makes it replay again on 2.2.2, so the media in the replayed tool results is still the load-bearing part — the failure is not in the text history.
What I am measuring next: total chars held constant while the image count varies (1 / 2 / 4 / 8), to separate the two factors. If you already know which conversion is size-dependent — the one that would start returning a different type past a cutoff — that would let me test the right knob instead of inferring it from black-box boundaries.
HX-Wrdzgzs commentedon Oct 30, 2026
Progress: I opened a draft PR against
devfor the half of this I could actually verify locally — #60604Verified in code and at unit level:
toolResultText's json branch calledencodeJson(...)with no guard.{ type: "json", value: undefined }— whichToolOutput.toResultValueproduces when a tool returnedundefinedand emitted no content parts (tool.ts:262->schema/messages.ts:115) — makesSchema.encodeSync(Json)throwExpected a JSON-serializable value, which is the exact string in these reports. Only a top-levelundefinedthrows;{a:undefined}and[undefined]both encode fine.LLM.compilehad nocatchCause, so that throw was a defect that unwound throughSessionStep.attemptintoSessionRunner.drain: nothing reached the wire, and the session never recovered — every later turn, including a bare "continue", failed identically. It now becomes a typedAIErrorfor that one step.What I am not claiming: I still cannot show this is the same trigger as the large pasted-image failures here. My boundary data (1,732,364 chars pass / 2,161,400 fail; a 10-image turn at 1,642,840 fail) is not explained by this path, and the PR carries no end-to-end reproduction. What the second hunk guarantees regardless of the trigger is that a lowering throw can no longer kill the session, which is the unrecoverable half of the symptom.
Still open on my side: capturing the first
undefinedthat actually reaches lowering on a real image-heavy turn.HX-Wrdzgzs commentedon Oct 31, 2026
A second, distinct trigger for the same fatal path — deterministic, and not the image path:
Trigger. A context-pruning plugin rewrites the
tool-resultpart and stores the wholeresultas a bare string:{"type":"tool-result","id":"call_575531866a004e69a5872af4","name":"read","result":"[已裁剪 · 原 238 token] 完整信息请点击 tm_fetch { ref: \"tm://runs/…/steps/s0003/result\", access_token: \"<redacted>\" }","providerExecuted":false}part.result.typeisundefined, sotoolResultTextreachesencodeJson(undefined)→SchemaError: Expected a JSON-serializable value, thrown insideLLM.compileas a defect and unwound throughSessionStep.attemptintoSessionRunner.drain.Real session, desktop 2.2.3:
2026-10-30T15:56:44.495ZFailed to drain Session2026-10-30T15:56:48.676Z— 4.18 s laterOpenAIChat.fromRequest ← LLM.compile ← SessionStep.attempt ← SessionRunner.runStep ← SessionRunner.drainSame session after fixing the producer (result kept as
{type, value}): it resumes on 2.2.5 fromidle_outcome='failed', finishes the turn (idle_outcome='succeeded'), and shows zero furtherFailed to drain.Why it stays invisible on 2.2.5 without the plugin: pruning only fires above 70% context; our 2.2.5 runs stayed at 68% / 35%, so the malformed part never appeared. On 2.2.3 the same plugin pruned 2,363 parts / 1.68 M tokens in one session.
PR #60604 guards both ends: shape-aware
toolResultText,make/toResultValuethat cannot build{type:"json", value:undefined}, and aLLM.compilethat converts a non-interrupt cause into a typedAIError.This is not the large-image trigger, but the failure mode —
encodeJson(undefined)as a defect that killsdrain— is identical.