Skip to content

SchemaError: Expected a JSON-serializable value while compiling an OpenAI Responses request fatally kills the session #58934

Open

Description

@HX-Wrdzgzs

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)

  1. media.image.max_width/max_height = 540/1212.
  2. In one session, have the agent read a screenshot (~540×1212 PNG, ~150 KB of base64)
    seven times, interleaved with normal tool calls.
  3. Send any further message. The step fails with the error above.

B. Single large image

  1. Same config, but read one image whose inline base64 exceeds ~1.3 MB.
  2. 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 27 image_url edges pointing at strings (the 2 closure-valued
    ones are code templates inside JSModuleEnvironment, not request data), and the
    response.create request strings were truncated by V8 at 1 KB — i.e. the bad object
    had already been collected. --log-level trace does not add any structure to the error.

Suggested fixes (three independent defects)

  1. 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.
  2. 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.
  3. Do not kill the session. An assembly failure should fail the step with a
    retryable/explainable error (and ideally offer compact / "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

  1. opencode-agent commented on Oct 25, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed write-up. I tried to reproduce this on 2.1.4 (Linux, opencode run --standalone) with your media.image settings (auto_resize: true, 540×1212 max), using a local mock of the Responses API:

    • An OpenAI-compatible Responses provider over HTTP, with images attached to prompts and then added to the same session until about 9 MB of inline image data was in the request.
    • The agent 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.
    • The same seven reads through the built-in openai provider pointed at the mock, which uses the WebSocket (response.create) transport.

    Every turn completed, and SchemaError: Expected a JSON-serializable value never appeared, so it seems to depend on something in your setup that I didn't match. These would help:

    1. Your opencode.json (with secrets removed), especially the provider entry: is openai the built-in provider with a custom baseURL, a custom provider with its own package, or a ChatGPT (OAuth) login? Any settings.transport or settings.encoding?
    2. The log lines around the failure from opencode.log, including the lines just before the error (provider, transport, request size if shown).
    3. Whether it also happens with the CLI from npm (@opencode/cli), or only with the desktop app's bundled opencode-cli.exe on Windows.
    4. If possible, a screenshot from a session that fails, or a minimal set of steps on a fresh session that triggers it.

    Related reports where images left a session unusable, though those were errors from the provider rather than this local one: #21668, #47487, #54690.

  2. HX-Wrdzgzs commented on Oct 25, 2026

    @HX-Wrdzgzs
    Author

    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. openai is the built-in provider (there is no openai entry under providers), no settings.transport, no settings.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 info there is no provider / transport / request-size line at all — the only record is the whole cause on a single line:

    timestamp=2026-10-25T03:30:13.701Z level=ERROR message="Failed to drain Session"
    cause="SchemaError: Expected a JSON-serializable value\n    at OpenAIResponses.fromRequest (B:/~BUN/root/chunk-m9c0z1h3.js:6:23866)\n    at OpenAIResponses.fromRequest (definition) (B:/~BUN/root/chunk-tw78hptr.js:2:5770)\n    at LLM.compile (B:/~BUN/root/chunk-m9c0z1h3.js:6:24210)\n    at LLM.compile (definition) (B:/~BUN/root/chunk-m9c0z1h3.js:6:23788)\n    at SessionStep.attempt (B:/~BUN/root/chunk-6hz4z8ma.js:366:3581)\n    ..."
    

    --log-level trace adds 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

    # gen.py  (Pillow) — density matters, not resolution
    import random
    from PIL import Image
    W, H, FRAC = 891, 2000, 0.90          # FRAC = share of incompressible pixels
    raw = bytearray(W * H * 4)
    n = int(len(raw) * FRAC) // 4 * 4
    raw[:n] = random.Random(1042).randbytes(n)
    for i in range(n, len(raw), 4):
        raw[i], raw[i+1], raw[i+2], raw[i+3] = 18, 18, 20, 255
    Image.frombytes("RGBA", (W, H), bytes(raw)).save("crash90.png", format="PNG", compress_level=6)
    opencode run --standalone -s ses_fresh_1 --model openai/gpt-6-luna#max \
      "Use read on /abs/path/crash90.png then reply with exactly: ok"
    

    The read result puts the image in history; the next compile of the request body fails. media.image.auto_resize does 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-in openai, openai/gpt-6-luna#max, one image per fresh session, one repetition each:

    incompressible share stored base64 (540x1212) result
    15 % 649 KB pass
    35 % 1485 KB pass
    55 % 2318 KB pass
    62 % 2611 KB pass
    66 % 2776 KB CRASH
    70 % 2944 KB pass ← non-monotonic
    75 % 3154 KB CRASH
    90 % 3410 KB CRASH

    Repeating the same file, same command, same config:

    stored base64 3 runs
    2776 KB pass / CRASH / pass
    2944 KB CRASH / CRASH / CRASH

    And with the npm CLI, npx -y @opencode/[email protected] run --standalone, same 3410 KB image:

    run 1 run 2 run 3
    pass CRASH pass

    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

    • A per-request byte threshold: 2944 KB passed while 2776 KB failed.
    • Session length / compaction: two of the sessions that did die had 17–24 messages and zero compactions.
    • Desktop-bundle-only: npm CLI reproduces.
    • maxBase64Bytes as a guard: setting it (tried both max_base64_bytes and maxBase64Bytes, e.g. 1500000) changes nothing — the 2944 KB image is still stored unchanged and still crashes/passes at random. Note the default is 5242880, so the image never reaches that limit and no exceeding configured limits error is ever raised.

    5. Where I would look

    From the bundle, the image service is built around a lazily imported module:

    i = yield* Wu(de({try:()=>import("B:/~BUN/root/chunk-6yp5xank.js"), catch:()=>new OU}).pipe(m(r=>r.make))),
    n = c("Image.normalize")(function*(r,o){ return yield* (yield*i)(r,o,e.get()) })

    and the part is assembled as

    let h = y_(e.media), x = h ?? (yield* zc(i.name, e.media)).dataUrl;
    return { type: "input_image", image_url: x, detail: p }

    So a non-string / non-JSON image_url can 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 all image_url edges 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

    • The error carries no field path, so this is undebuggable from a user's side (--log-level trace does not help).
    • There is no degradation: the Image.SizeError → [n image omitted] path exists but this path never reaches it.
    • It kills the session permanently — the same history is recompiled identically on every later turn, so the conversation can only be recovered by rewriting image bytes in the session database. Options (a) path + type in the cause, (b) fall back to the placeholder, (c) fail the step rather than the session, would each turn a dead conversation into a retryable error.

    Happy to run anything you want: instrumented build, traces, extra seeds/iterations, or a specific branch.

  3. HX-Wrdzgzs commented on Oct 25, 2026

    @HX-Wrdzgzs
    Author

    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 read never 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:

    setup total inline base64 results
    1 image 2318 KB pass
    1 image 2611 KB pass
    1 image 2776 KB pass / CRASH / pass / CRASH
    1 image 2945 KB pass / CRASH / CRASH / CRASH
    4 images x ~356 KB 1426 KB CRASH / CRASH
    2 images x ~712 KB 1424 KB CRASH / CRASH
    8 images x ~109 KB 873 KB pass / pass
    4 images x ~109 KB 436 KB pass / pass

    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:

    • not a total-byte threshold — 1426 KB spread over 4 images always dies, while 2611 KB in one image passes;
    • not a per-image size threshold — 109 KB items are fine up to 8 of them, 356 KB items die at 4;
    • not image count alone — 8 tiny images pass;
    • it is deterministic given one session's history (10/10 on retry) but varies between fresh sessions carrying byte-identical images, so the discriminating factor lives in something that changes per session outside the image bytes.

    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: read truncates 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.

  4. Te-River commented on Oct 25, 2026

    @Te-River

    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:

    cause="SchemaError: Expected a JSON-serializable value
        at OpenAIChat.fromRequest (chunk-m9c0z1h3.js:6:23866)
        at OpenAIChat.fromRequest (definition) (chunk-g0d98h1a.js:4:2417)
        at LLM.compile (chunk-m9c0z1h3.js:6:24210)
        at SessionStep.attempt ..."
    

    Same bundle position as your OpenAIResponses.fromRequest frame, so it is one shared code path rather than something specific to input_image.

    That matters because the failure is not image_url rejecting a non-string. The encoder is encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)), and its only failure mode is JSON.stringify returning undefined or throwing — i.e. the value is undefined/a function/a symbol, or the object is circular or contains a BigInt. stringifyJson maps all of those to SchemaIssue.InvalidValue({ expected: "a JSON-serializable value" }).

    So the question is which value inside fromRequest is being stringified, not which provider field it lands in. In the Chat path there are two calls that serialize history: the tool-call arguments (openai-chat.ts:371, encodeJson(part.input ?? {})) and the tool result text (shared.ts toolResultText). 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 require image_url itself 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.

  5. HX-Wrdzgzs commented on Oct 25, 2026

    @HX-Wrdzgzs
    Author

    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-in openai provider 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, no messages). Every request logged with its exact byte size and parsed structure.

    What the bodies show

    1. Images are not in an input_image message item. They arrive as function_call_output.output[1].image_url — i.e. inside the read tool result.
    2. Body image characters == session-store characters, exactly, 1:1. 3,016,900 = 3,016,900; 894,304 = 894,304 across 8 slots. The client does not duplicate, split or rewrite them.
    3. The turn that ingests an image succeeds — even at 3.0 MB. Measured body 3,073,236 B, stream completed, ok returned.
    4. The next turn, which replays the same image from history, is where it dies — and that turn sends nothing at all (10 turns across the four cases produced zero requests; the throw happens in LLM.compile, before transport).

    The replay boundary (deterministic)

    Single image per fresh session: run 1 = read it, run 2 = a text-only prompt (so run 2's request must replay the stored image).

    replayed inline base64 (chars) ingest turn replay turn
    1,016,784 pass pass
    1,085,168 pass pass
    1,126,972 CRASH (ingest is flaky, see below) CRASH
    1,165,600 CRASH CRASH
    1,315,688 pass CRASH
    1,461,020 pass CRASH
    2,674,424 pass CRASH
    3,016,900 pass CRASH

    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_url inside the replayed function_call_output items? 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)

    # turn 1 — ingest (passes, this is expected)
    opencode run --standalone -s ses_x --model openai/gpt-6-luna#max \
      "Use read on /abs/path/dense.png then reply exactly: ok"
    
    # turn 2 — replay (this is the failing one; sends nothing)
    opencode run --standalone -s ses_x --model openai/gpt-6-luna#max \
      "Reply exactly: ok"
    

    dense.png = 891×2000 RGBA PNG, ~89 % incompressible pixels (generator in my earlier comment). auto_resize shrinks 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_output media, 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.

  6. HX-Wrdzgzs commented on Oct 25, 2026

    @HX-Wrdzgzs
    Author

    That is a very useful narrowing — thanks. OpenAIChat.fromRequest at the same bundle column as our OpenAIResponses.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 with npx @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:

    replayed inline base64 result
    turn that ingests the image 3,016,900 chars passes, body 3,073,236 B on the wire
    next turn, replaying the same image 3,016,900 chars fails, zero bytes sent

    and the replay boundary is sharp and deterministic (fresh session, one image, second turn is a text-only prompt):

    replayed chars replay turn
    1,016,784 pass
    1,085,168 pass
    1,126,972 fail
    1,165,600 fail
    1,315,688 fail
    1,461,020 fail
    2,674,424 fail

    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 toolResultText lead: if the stored result is turned back into a media/file part and the text extractor yields undefined for it, encodeJson(undefined) hits exactly your InvalidValue({ 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.

  7. Te-River commented on Oct 25, 2026

    @Te-River

    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 your OpenAIResponses.fromRequest frame. So this is one shared lowering path, not something specific to input_image.

    What actually throws. encodeJson is Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)), and its encode getter is JSON.stringify. The only way it produces Expected a JSON-serializable value is JSON.stringify returning undefined or throwing — i.e. the value is undefined/a function/a symbol, or the object is circular or holds a BigInt. It is not a field-shape check, so image_url being a string does not rule it out.

    The value. A tool result of the form { type: "json", value: undefined }:

    toolResultText(part)            // protocols/shared.ts, json branch
      -> encodeJson(undefined)
      -> JSON.stringify(undefined) === undefined
      -> SchemaIssue.InvalidValue({ expected: "a JSON-serializable value" })
    

    That reproduces locally: the same part with value: null returns "null". The part is constructible because ToolResultValue.make(value, "json") (ai/src/schema/messages.ts) returns { type, value } without guarding undefined; one producer is core/src/aisdk.ts, ToolResultValue.make(event.result, ...) where event.result can be undefined.

    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.encodeSync throws a ParseError, which is a defect, not an AIError. session/step.ts captures with Effect.exit and reads Exit.findErrorOption, which only looks at the typed channel, so llmFailure stays undefined, and the retry/toSessionError paths are skipped. step.ts then re-raises the cause, which escapes SessionRunner.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): normalize undefined to null in ToolResultValue.make so the invalid state cannot be constructed; make the json branch of toolResultText total by using the existing Schema.encodeUnknownOption encoder with a text fallback; and wrap route.body.from in Effect.catchCause so a lowering defect becomes a route-scoped typed AIError (interrupts and typed failures pass through unchanged). With that, the step fails with a readable error instead of killing the session.

    packages/ai is 1924 pass, and the lowering-defect case fails on dev and passes here.

  8. HX-Wrdzgzs commented on Oct 25, 2026

    @HX-Wrdzgzs
    Author

    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 only text + file parts — no {"type":"json"} anywhere in the store, and no message containing such a part. So there is nothing for the json branch of toolResultText to 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=succeeded afterwards, three times on the same session). So in our case the offending value is derived from persisted history, which rules out an event.result === undefined producer 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:

    replayed inline base64 turn 1 (ingest) turn 2 (replay)
    1,016,784 pass pass
    1,085,168 pass pass
    1,126,972 pass fail
    1,315,688 pass fail
    3,016,900 pass (body 3,073,236 B on the wire) fail

    Failing turns send zero bytes. "plugins": [] changes nothing, and the same happens on npx @opencode/[email protected], so it is not the desktop bundle or a plugin.

    If the same encodeJson is involved, the value being stringified there must come from the media/file part of a replayed tool result rather than from a json part — i.e. a second producer feeding the same encoder. The size dependence and the "replay only" asymmetry both point at whatever rebuilds the stored type: "file" part ({uri: "data:image/png;base64,…", mime}) into the request.

    The decisive test: does your json-tool-result branch 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 for file parts needs the same Effect.catchCause treatment you added around route.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 (read a dense 891×2000 PNG, then send any text-only prompt).

  9. Te-River commented on Oct 25, 2026

    @Te-River

    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 (protocol open-responses, the same one your config reaches), image carried by a read tool result, compiled through the real LLM.compile path.

    inline base64 in the tool result compile
    1,016,784 ok
    1,085,168 ok
    1,126,972 ok
    1,315,688 ok
    3,016,900 ok

    The body reaches the transport with the image in function_call_output, and nothing is dropped. So on dev the 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.stringify returning undefined, there are only three ways in: the value is undefined/a function/a symbol, the object is circular, or it holds a BigInt. 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. A JSON.stringify replacer that records the first undefined/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.make refuses to construct the undefined case, 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.

  10. HX-Wrdzgzs commented on Oct 25, 2026

    @HX-Wrdzgzs
    Author

    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_resize to 540x1212, turn 1 ingests, turn 2 is a text-only prompt (so it must replay stored media).

    how the image got in path in the outgoing body body bytes image chars in body replay turn
    read tool .input[].function_call_output.output[1].image_url (never sent) 1,222,100 fail, 0 bytes sent
    read tool same 954,926 (8 x ~109 KB case) 894,304 pass
    --file attachment .input[0].content[1].image_url 1,277,834 1,222,122 pass
    --file attachment .input[0].content[1].image_url 3,072,018 3,016,306 pass

    So:

    • It is not input_image vs image_url, and not "the string is too long" — in both cases the body carries a plain data:image/png;base64,... string, and the user-message slot survives 3.0 MB of it, replayed twice, with the body measured on the wire.
    • It is not the media encoder by itself: identical bytes, identical encoder, one path passes and one throws.
    • It is the lowering of a tool result that carries media. Boundary there is deterministic and sharp: 1,085,168 chars pass, 1,126,972 fail, and a session that fails keeps failing (10/10).

    That points straight at the toolResultText neighbourhood rather than at a separate media path — but the producer is not the json branch. In our four dead sessions the tool results are type: "content" with text + file parts; there is no {"type":"json"} part anywhere in the store. So whatever calls encodeJson there 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:

    # turn 1 — ingest via tool result (passes)
    opencode run --standalone -s ses_x --model <responses-model> \
      "Use read on /abs/path/dense.png then reply exactly: ok"
    # turn 2 — replay (fails today, 0 bytes sent)
    opencode run --standalone -s ses_x --model <responses-model> "Reply exactly: ok"
    

    and the control that must pass on any fixed build:

    opencode run --standalone -s ses_x2 --model <responses-model> --file /abs/path/dense.png "Reply exactly: ok"
    opencode run --standalone -s ses_x2 --model <responses-model> "Reply exactly: ok"
    

    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.

  11. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    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.image settings (auto_resize: true, 540×1212), going through the OpenAI Responses route to a local mock server. The agent read seven 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 saw SchemaError: Expected a JSON-serializable value.

    That error text is what Effect reports when JSON.stringify throws 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:

    • Does it also happen with the opencode CLI from the npm package, not just the desktop app's opencode-cli.exe?
    • If you can share one PNG that triggers it on its own (your 1.3 MB+ case B) with nothing sensitive in it, that would make it much easier to reproduce.

    Related: #52042 (an image rejected by the provider leaves the session permanently failed) and #52448 (a binary MCP attachment breaks the session).

  12. HX-Wrdzgzs commented on Oct 28, 2026

    @HX-Wrdzgzs
    Author

    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.

    1. The npm CLI reproduces it too.

    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.

    1. There is a non-sensitive synthetic PNG reproducer.

    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.

    1. The important discriminator is the second turn and where the image came from.

    My "latest controlled comparison" (#58934 (comment)) uses a local Responses mock and captures actual request bodies.

    • Image from "read" tool: The first turn succeeds. The following text-only turn, which replays the stored tool result, fails inside "LLM.compile" before any request reaches the mock server.
    • Image from "--file" attachment: The following text-only replay succeeds, even with approximately 3 MB of inline image data.
    • Smaller images from "read": Replay also succeeds.

    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.

  13. HX-Wrdzgzs commented on Oct 29, 2026

    @HX-Wrdzgzs
    Author

    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

    certutil -decode repro_dense.png.b64 repro_dense.png   # Windows
    base64 -d repro_dense.png.b64 > repro_dense.png        # Linux / macOS
    • repro_dense.png — 1,915,429 bytes, sha256 A7EA36526F10B60CCAC8E15E30CBC8097B96FEB7F983F137B89C109FCBEA0D19
    • 891×2000 RGBA. Synthetic: seed 1042, ~89 % random pixels, rest filled (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 own media.image downscale 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.
    • I re-downloaded the gist and decoded it here: same sha256, byte count and PNG magic. So the upload is not lossy.

    The Windows side (this is the half that is missing from the Linux runs)

    OS Windows 11 Pro for Workstations, build 26300 (10.0.26300), x64
    CPU / RAM i5-class desktop, 31.8 GB
    runtime 1 desktop bundle opencode-cli.exe v2.1.4 (…\ai.opencode.desktop\cli\2.1.4-x64\opencode-cli.exe)
    runtime 2 npx -y @opencode/[email protected] — v2.1.4, node v24.18.0
    model openai/gpt-6-luna#max (Responses)
    media 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 = read the image, turn 2 = a text-only prompt so it must replay the stored tool result):

    replayed inline base64 turn 1 (ingest) turn 2 (replay)
    1,016,784 pass pass
    1,085,168 pass pass
    1,126,972 pass fail
    1,165,600 fail (flaky) fail
    1,315,688 pass fail
    2,674,424 pass fail
    3,016,900 pass (body 3,073,236 B on the wire) fail

    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 --file attachment replays fine at 1,222,122 and 3,016,306 characters (body 3,072,018 B measured), while through a read tool result it fails at 1,222,100. Both arrive as a plain data: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 dev and 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.stringify replacer 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 SchemaError on 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".

  14. HX-Wrdzgzs commented on Oct 29, 2026

    @HX-Wrdzgzs
    Author

    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.exe v2.2.1, model openai/gpt-6.1-sol#max (Responses), media.image { auto_resize: true, 540x1212 }.

    Method is the same two-turn test as above: turn 1 = read a 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.outcome plus the attached error), not from stdout:

    version replayed inline base64 turn 1 turn 2 (replay)
    2.1.4 1,085,168 pass pass
    2.1.4 1,126,972 pass fail
    2.2.1 1,166,004 pass pass
    2.2.1 1,315,744 pass pass
    2.2.1 1,732,364 pass pass
    2.2.1 2,161,400 pass fail — Expected a JSON-serializable value
    2.2.1 3,016,284 pass fail — same

    So 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:

    1. Any fix aimed at this needs to be tested at or above ~2.2 M replayed characters, or it will look green. The boundary moving by ~0.6 M between releases is why a test at "just above 1 MB" can pass on one build and fail on another without anything being fixed.
    2. A sharp step that tracks the version suggests the value is produced by a size-dependent conversion rather than a fixed bad field — consistent with Te-River's suggestion of a JSON.stringify replacer recording the first undefined/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, message Expected 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.

  15. HX-Wrdzgzs commented on Oct 30, 2026

    @HX-Wrdzgzs
    Author

    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 (openai Responses), same media.image config:

    case replayed media image count result
    synthetic, single image 1,732,364 chars 1 pass (2.2.1)
    synthetic, single image 2,161,400 chars 1 fail (2.2.1)
    real session, ten UI screenshots 1,642,840 chars 10 fail (2.2.1, 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.

  16. HX-Wrdzgzs commented on Oct 30, 2026

    @HX-Wrdzgzs
    Author

    Progress: I opened a draft PR against dev for the half of this I could actually verify locally — #60604

    Verified in code and at unit level:

    • toolResultText's json branch called encodeJson(...) with no guard. { type: "json", value: undefined } — which ToolOutput.toResultValue produces when a tool returned undefined and emitted no content parts (tool.ts:262 -> schema/messages.ts:115) — makes Schema.encodeSync(Json) throw Expected a JSON-serializable value, which is the exact string in these reports. Only a top-level undefined throws; {a:undefined} and [undefined] both encode fine.
    • LLM.compile had no catchCause, so that throw was a defect that unwound through SessionStep.attempt into SessionRunner.drain: nothing reached the wire, and the session never recovered — every later turn, including a bare "continue", failed identically. It now becomes a typed AIError for 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 undefined that actually reaches lowering on a real image-heavy turn.

  17. HX-Wrdzgzs commented on Oct 31, 2026

    @HX-Wrdzgzs
    Author

    A second, distinct trigger for the same fatal path — deterministic, and not the image path:

    Trigger. A context-pruning plugin rewrites the tool-result part and stores the whole result as 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.type is undefined, so toolResultText reaches encodeJson(undefined) → SchemaError: Expected a JSON-serializable value, thrown inside LLM.compile as a defect and unwound through SessionStep.attempt into SessionRunner.drain.

    Real session, desktop 2.2.3:

    • spawn 2026-10-30T15:56:44.495Z
    • Failed to drain Session 2026-10-30T15:56:48.676Z — 4.18 s later
    • OpenAIChat.fromRequest ← LLM.compile ← SessionStep.attempt ← SessionRunner.runStep ← SessionRunner.drain

    Same session after fixing the producer (result kept as {type, value}): it resumes on 2.2.5 from idle_outcome='failed', finishes the turn (idle_outcome='succeeded'), and shows zero further Failed 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/toResultValue that cannot build {type:"json", value:undefined}, and a LLM.compile that converts a non-interrupt cause into a typed AIError.

    This is not the large-image trigger, but the failure mode — encodeJson(undefined) as a defect that kills drain — is identical.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions