Skip to content

Failed to drain Session: one malformed tool result wedges the whole session #50775

Open

Description

@twotimemonger

Description

Had a few sessions wedge themselves last night (desktop 2.0.12, still happening on 2.0.14). Every prompt in an affected session fails with Failed to drain Session, forever. New sessions are fine. Saw it 33 times in one evening across several sessions.

From ~/.local/share/opencode/log/opencode.log:

ERROR Failed to drain Session error=TypeError: s.result.value.map is not a function. (In 's.result.value.map((a) => {
  if (a.type !== "file") return a;
  return pm(a.mime, a.name, o) ?? a;
})', 's.result.value.map' is undefined)
    at <anonymous> (chunk-bypdxy5k.js:113:1087)
    ...
    at prepare (chunk-bypdxy5k.js:114:784)
    at runStep
    at drain

That minified snippet is unsupportedParts in packages/core/src/session/model-request.ts:

if (part.type !== "tool-result" || part.result.type !== "content") return part
return { ...part, result: { ...part.result, value: part.result.value.map(...) } }

So somewhere a tool-result reaches prepare with result.type === "content" but a value that isn't an array, the .map throws, and the throw takes out runStep -> drain, which is why the session can never recover. boundImages a few lines below has the same assumption in two places (a .reduce and a .map).

I poked at opencode.db for one of the dead sessions first, assuming something bad got persisted, but all 70 stored tool parts look completely normal (state: {status, input, content: [...]}). So whatever produces the bad part happens in memory during request prep, not on write. Couldn't catch the actual producer, unfortunately.

Wherever it comes from, one malformed tool result shouldn't be able to brick a whole session. Opened #50776 which degrades those parts to a text placeholder in unsupportedParts/boundImages instead of throwing.

Plugins

opencode-auth, opencode-openai-codex-auth, zztkm-vdot, plus a local dev plugin (open-toolbox)

OpenCode version

Desktop app 2.0.14 (bundled CLI); first seen on 2.0.12

Steps to reproduce

  1. Have a session whose prepared request contains a tool-result part with { result: { type: "content", value: undefined } } (no reliable way to force this - it just happens sometimes mid-session, provider was opencode zen via the openai-compatible endpoint)
  2. Send any message in that session (or let it resume on app start)
  3. Every drain fails with the TypeError above; the session is permanently wedged. New sessions work fine.

Operating System

Windows 11

Terminal

n/a (desktop app)

Activity

  1. rekram1-node commented on Sep 23, 2026

    @rekram1-node
    Collaborator

    Your stored tool parts look fine, so something is putting a non-array value on a tool result in memory right before the request, most likely a plugin hook editing event.messages. Does the session work again if you disable your plugins (especially open-toolbox), and does any of them rewrite tool results, like truncating old output into a string?

  2. twotimemonger commented on Sep 23, 2026

    @twotimemonger
    Author

    It was my plugin, I wrote a string value into a tool result. I fixed that on my end, but I kept #50776 open so no hook can permanently wedge a session: it degrades to a placeholder, keeping the contract that hooks can edit messages while core still drains. you can close if this is not the correct direction.

  3. PestoAddict commented on Oct 7, 2026

    @PestoAddict

    Different trigger, same permanent wedge — plus a recovery that works.

    Environment

    • opencode 2.0.23, macOS Darwin 25.6.0 (arm64), zsh, TERM=xterm-256color
    • provider: opencode-go (openai-compatible); 5 local plugins

    What happened
    A subagent session stopped producing steps mid-task: 68 min wall clock for 86 s of real model generation (14 steps). It sat in running for 33 min with zero output, ran a few steps, then sat for another 18 min.

    Log, in the middle of the first stall:

    16:22:20Z ERROR message="Failed to drain Session"
      cause="AI.Error: Upstream request failed: [invalid_request_error] a request may include at most 20 images"
    

    So the trigger here is not the TypeError in unsupportedParts — it is an upstream 400 rejecting a request that carried more images than the provider allows. Same outcome: after the failed drain the session is never scheduled again, nothing is surfaced to the client, and the parent agent blocks.

    It can take more than one session down at once. At one point /api/session/active listed 7 sessions in running with tokens.output frozen. /api/permission/request and /api/session/<id>/permission were both empty — nothing was waiting on an approval. A wedged session produces no log line of its own, so it looks idle rather than failed.

    Recovery that works: opencode reload. 2/2 wedged sessions resumed within the exact second of a config/plugin reload — the log shows watcher stopped → "loading plugin" ... at the same second the next step finally ran (16:53:14Z, 17:23:13Z). So the state is recoverable when the runner re-drains: a retry/reset on drain failure would unwedge it without a manual reload, and the failure should reach the client instead of silence (see #51113).

    Question: should a request exceeding the provider's image limit be bounded before send, and/or should that 400 become a per-session error rather than a wedged runner?

  4. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I reproduced the wedge, though I didn't find what creates the malformed part in your sessions. To test the crash itself, I used a small local plugin whose session context hook rewrites tool results to { type: "content", value: undefined }, then had the model call read.

    • On 2.0.14, the step after the tool result fails with Failed to drain Session … TypeError: undefined is not an object (evaluating '…result.value.map'). The model is never called again, every later prompt in that session fails the same way, and the TUI just sits idle with no error shown.
    • It still happens on the latest dev (fa1260d6). The throw comes from replaceMedia, which unsupportedParts uses, in packages/core/src/session/model-request.ts. boundImages makes the same array assumption (.flatMap).

    The script and recording below show the steps. The script prints the opencode.log lines with the error. So one malformed tool result can wedge a whole session, as you described. Where the bad part comes from in your setup is still open. Your plugins (including the local open-toolbox) can rewrite messages through session hooks, so they are worth checking. Related: #55271 and #53633 (other request-preparation errors that end a session's drain), #52042 and #54325 (other sessions that get stuck with no way to recover).

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    import { mkdirSync, writeFileSync } from "node:fs"
    import { execSync } from "node:child_process"
    
    // A local plugin standing in for whatever produced the malformed part: its session
    // "context" hook rewrites every tool-result to { type: "content", value: undefined }.
    const PLUGIN_DIR = "/tmp/opencode/corrupt-plugin"
    mkdirSync(PLUGIN_DIR, { recursive: true })
    writeFileSync(`${PLUGIN_DIR}/server.js`, '// Simulates whatever produces the malformed part: in the session "context" hook,\n// rewrite every tool-result to { type: "content", value: undefined }.\nimport { appendFileSync } from "node:fs"\nconst log = (m) => { try { appendFileSync("/tmp/opencode/corrupt-plugin.log", m + "\\n") } catch {} }\nexport default {\n  id: "corrupt-tool-result",\n  setup: async (ctx) => {\n    log("setup")\n    await ctx.session.hook("context", (event) => {\n      log("context " + JSON.stringify(event.messages.map((m) => Array.isArray(m.content) ? m.content.map((p) => p.type) : typeof m.content)))\n      event.messages = event.messages.map((message) =>\n        Array.isArray(message.content)\n          ? {\n              ...message,\n              content: message.content.map((part) =>\n                part.type === "tool-result" ? { ...part, result: { type: "content", value: undefined } } : part,\n              ),\n            }\n          : message,\n      )\n    })\n  },\n}\n')
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        project: { git: true, files: { "notes.txt": "hello\n" } },
        config: { plugins: process.env.NO_PLUGIN ? [] : [PLUGIN_DIR] },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          yield* llm.title(() => Effect.succeed("Repro session"))
          // First model turn calls the read tool; any later turn would just answer "ok".
          // (Title requests also come through here, so pick the turn by its content.)
          let called = false
          yield* llm.serve((request) =>
            !called && JSON.stringify(request).includes("Read notes.txt") && JSON.stringify(request).includes('"read"') && (called = true)
              ? Stream.make(
                  Llm.toolCall({ index: 0, id: "call_read", name: "read", input: { path: "notes.txt" } }),
                  Llm.finish("tool-calls"),
                )
              : Stream.make(Llm.text("ok"), Llm.finish()),
          )
          yield* ui.submit("Read notes.txt")
          yield* Effect.sleep("15 seconds")
          yield* ui.screenshot("after-first-prompt")
          yield* ui.submit("hello again")
          yield* Effect.sleep("15 seconds")
          yield* ui.screenshot("after-second-prompt")
          // Show the server-side error from the isolated run's opencode.log.
          const found = execSync(
            `grep -rhE "Failed to drain Session|level=(ERROR|WARN)" /tmp/opencode-drive/ 2>/dev/null | cut -c1-400 || true`,
          ).toString()
          yield* Effect.log(`opencode.log drain errors:\n${found || "(none found)"}`)
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions