Skip to content

ai: preserve diagnostics for mid-stream HTTP decode failures #42482

Open

Description

@kitlangton

Summary

RequestExecutor.httpError discards the native cause of an HTTP response-body read failure and omits safe stream-progress metadata. A mid-stream provider failure is therefore persisted and logged only as a generic Decode error (200 POST ...), making the underlying transport failure irretrievable.

Mentioning @kitlangton, who observed and investigated the incident.

Environment

  • opencode version: 0.0.0-next-17430
  • OS: macOS 25.5.0, Darwin arm64
  • Terminal: Ghostty 1.3.1 (TERM_PROGRAM=ghostty)
  • Shell: /bin/zsh
  • Install/channel: next
  • Active plugins: session-control.ts, opencode-voice, and the V2 debug workspace plugin

Reproduction

The production incident was intermittent, but the loss is deterministic with a test executor:

  1. Return an HTTP 200 response whose body stream emits one or more valid chunks.
  2. Reject the ReadableStream after those chunks with a nested native error or a cause that does not decode as { message: string }.
  3. Consume the response through RequestExecutor.stream.
  4. Inspect the resulting AIError, service log, and persisted session error.

Expected Behavior

The failure should retain safe, structured diagnostics without retaining response payloads:

  • HTTP operation and status
  • Normalized native error tag/class and code
  • Bounded cause-chain tags/codes
  • Chunks and bytes successfully read
  • Decoded event count and last event type
  • Whether a terminal protocol event was observed
  • Content type/encoding and elapsed time

The native payload, generated text, reasoning content, tool arguments, credentials, arbitrary headers, and raw SSE frames should not be logged or persisted.

Actual Behavior

The assistant message was persisted with only:

{
  "type": "provider.transport",
  "message": "Decode error (200 POST https://chatgpt.com/backend-api/codex/responses)"
}

The service log contained the same generic message as both the AIError and nested cause. Nine reasoning-summary events had been persisted before the body read failed, but no answer text or terminal response event arrived. A later retry succeeded and the service remained healthy.

Additional Context

The information is lost along this path on current v2:

  1. Effect captures the native ReadableStream rejection as DecodeError.cause in effect/unstable/http/HttpClientResponse.ts.
  2. packages/ai/src/route/executor.ts unwraps reason.cause, but nativeTransportFailure only decodes a shallow { message, code, cause } shape.
  3. If decoding the source fails, httpError falls back to input.error.message, the outer generic decode message, rather than inspecting a bounded cause chain.
  4. httpError constructs a replacement AIError without the original causal chain and includes only request context for read failures, despite the HttpClientError carrying the response.
  5. packages/core/src/session/to-session-error.ts deliberately persists only the projected session error type, message, and optional status.

Suggested minimal changes:

  1. Recursively inspect a bounded cause chain and retain only normalized tags/classes and codes.
  2. Include allowlisted response metadata and HTTP status for read failures.
  3. Count chunks/bytes at RequestExecutor.stream and decoded event metadata at the protocol boundary without retaining payloads.
  4. Emit one structured sanitized diagnostic when a stream fails, including failures that will be retried.
  5. Add a regression test for a successful HTTP 200 stream that emits valid chunks and then fails.
  6. Replace arbitrary redacted diagnostic headers/query values with explicit allowlists.

Raw response retention is intentionally not proposed: truncation limits size, not sensitivity, and even a short SSE frame may contain generated text, prompts, tool arguments, credentials, account identifiers, or encrypted reasoning.

Related but distinct: #41320 reports a Cloudflare 1010 disconnection. This issue concerns preservation of sanitized diagnostics after any mid-stream response-body failure.

Activity

  1. JavaGT commented on Aug 21, 2026

    @JavaGT

    Confirming this at production scale, and it's worse for multi-agent workflows than the log line suggests.

    Environment

    • opencode version: 0.0.0-beta-17793
    • OS: macOS (Darwin arm64)
    • Terminal: tmux / xterm-256color
    • Shell: /bin/zsh
    • Install/channel: beta
    • Active plugins: none configured

    Production evidence (single day, one machine)

    From the server log (Failed to drain Session entries), all against https://opencode.ai/zen/v1/chat/completions:

    • 116 × AI.Error: RequestExecutor.read: Decode error (200 POST https://opencode.ai/zen/v1/chat/completions)
    • 32 × AI.Error: RequestExecutor.read: ECONNRESET: The socket connection was closed unexpectedly

    All of them fatal to the turn. Zero retried. The generic message is the only signal — there is no way to tell from the persisted error whether the provider truncated the body, sent malformed SSE, or the local socket died, which is exactly the diagnostic loss this issue describes.

    Why sub-agents make this acute

    This machine runs a long-running multi-agent program (~90 sub-agent sessions/day through the Zen free models). For orchestrators, each one of these failures is a worker that dies silently:

    • No terminal event reaches the parent session — from the coordinator's point of view the sub-agent is indistinguishable from "still working" or "stalled", so completion signals can't be trusted and the repo state has to be polled as ground truth.
    • Whatever the dead turn had in flight is lost; re-dispatching means re-running the whole task from scratch, not resuming.
    • Human operators fall back to manually nudging sessions with "continue" to kick a fresh request.

    The failure rate scales with fleet size, so heavy sub-agent users absorb this superlinearly. Preserving the native cause + stream-progress metadata (as the PR does) would let tooling distinguish provider-side truncation from local transport faults and route retries accordingly — that distinction is exactly what's missing today.

  2. Dante-dan commented on Oct 27, 2026

    @Dante-dan
    Contributor

    #52634 already fixes the generic decode message and native-cause unwrapping. I implemented the stream-progress part of this report in a focused reference patch.

    It counts chunks/bytes, decoded frames, terminal observation and monotonic elapsed time; records the last normalized LLM event tag and allowlisted content type/encoding; and projects these optional fields into persisted session errors. One new diagnostic logs only status, operation and this progress metadata.

    The regression emits two valid SSE chunks before rejecting the stream, checks persistence projection, and verifies generated text, native messages and arbitrary headers stay out of the new progress object and diagnostic. Validation: 1,938 AI tests passed (28 skipped), 13 session-error tests passed, and all 35 typecheck tasks passed. Lint passed with warnings.

    This addresses progress metadata; existing raw error/cause/header handling and bounded native cause-chain normalization remain outside this patch.

  3. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed write-up. I reproduced this with a test against RequestExecutor.stream: a mocked HTTP 200 response sends two valid SSE chunks, then the ReadableStream fails. The script below sets this up.

    Before #52634 (317dfbc): a failure whose cause has no string message produced exactly Decode error (200 POST https://chatgpt.com/backend-api/codex/responses) with code DecodeError, as you describe.

    On latest dev (651032b): that generic message is gone. Reads now fail with Connection lost while reading the response[: code: detail], and the HTTP 200 status and the native cause object are kept. Some diagnostics are still lost:

    • Undici-style TypeError("terminated") with a cause carrying UND_ERR_SOCKET: works; the message and code include UND_ERR_SOCKET.
    • Three-level chain with the code (ECONNRESET) on the innermost error: message is Connection lost while reading the response: terminated and code is undefined.
    • Error with a code (ERR_STREAM_PREMATURE_CLOSE) but no string message: message is just Connection lost while reading the response and code is undefined.
    • No chunk, byte or event progress metadata is recorded, and the session error still keeps only the message and status.

    The root cause is that nativeTransportFailure only decodes a { message: string } shape and looks at most one cause deep.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Run from a checkout of anomalyco/opencode (dev) after `bun install`: bash repro.sh /path/to/repo
    set -euo pipefail
    REPO=${1:-.}
    cat > "$REPO/packages/ai/test/mid-stream-decode.test.ts" <<'EOF'
    // Copy into packages/ai/test/ and run: cd packages/ai && bun test test/mid-stream-decode.test.ts
    import { describe, expect } from "bun:test"
    import { Effect, Stream } from "effect"
    import { HttpClientRequest } from "effect/http"
    import { AIError } from "../src/effect.js"
    import { RequestExecutor } from "../src/route.js"
    import { systemError, truncatedStream } from "./lib/http.js"
    import { it } from "./lib/effect.js"
    
    const req = HttpClientRequest.post("https://chatgpt.com/backend-api/codex/responses")
    const chunks = ['data: {"type":"response.reasoning_summary_text.delta"}\n\n', 'data: {"type":"response.reasoning_summary_text.delta"}\n\n']
    
    const run = (name: string, error: Error) =>
      it.effect(name, () =>
        Effect.gen(function* () {
          const executor = yield* RequestExecutor.Service
          let n = 0
          const err = yield* RequestExecutor.stream(executor, req).pipe(
            Stream.runForEach(() => Effect.sync(() => n++)),
            Effect.flip,
          )
          expect(err).toBeInstanceOf(AIError)
          const r = (err as AIError).reason as any
          console.log(name, JSON.stringify({ chunksRead: n, message: err.message, code: r.code, status: r.http?.status, keys: Object.keys(r) }))
        }).pipe(Effect.provide(truncatedStream(chunks, error))),
      )
    
    describe("mid-stream body read failure diagnostics", () => {
      run("shallow undici", new TypeError("terminated", { cause: systemError("UND_ERR_SOCKET", "other side closed") }))
      run("3-deep chain", new TypeError("terminated", { cause: new Error("wrapped", { cause: systemError("ECONNRESET", "reset") }) }))
      run("non-message cause", Object.assign(new Error(), { message: undefined as any, code: "ERR_STREAM_PREMATURE_CLOSE" }))
    })
    EOF
    cd "$REPO/packages/ai" && bun test test/mid-stream-decode.test.ts

    Run it: bash repro.sh

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