Skip to content

Duplicate function_call_output for reused tool-call IDs makes session unrecoverable #54588

Open

Description

@JustCax

Description

Session permanently stuck: every drain fails with
AI.Error.InvalidRequest: Duplicate function_call_output for call_id 'read:1'. Each function_call must have exactly one matching function_call_output.

Root cause (verified in session export): the model
(moonshotai/kimi-k3 via nim) reused tool-call IDs across turns —
msg idx 616 read:1=docs/.md, idx 617 read:1=
src/.java, and read:0 twice (idx 617, 619).
OpenCode forwards both outputs for one call ID, the AI SDK rejects
the request in SessionStep.attempt during drain.

The duplicate lives in persisted history, so session.revert +
retry does NOT fix it (verified 4x in opencode.log across 5 hours).
Session outcome=failed, unrecoverable in place. OpenCode should
namespace/dedupe tool-call IDs per turn, or make revert drop the
duplicated messages.

Plugins

superpowers@git+https://github.com/obra/superpowers.git

OpenCode version

2.0.22

Steps to reproduce

  1. Long session (~1100 assistant messages, model switches, compactions).
  2. Model emits parallel read calls, then reuses read:0/read:1 IDs for
    different files in later turns.
  3. Every subsequent drain fails with the Duplicate function_call_output error.
  4. session.revert + retry still fails.

Screenshot and/or share link

No response

Operating System

Windows 11

Terminal

No response

Activity

  1. opencode-agent commented on Oct 11, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced this with a simulated model on 2.0.22, and it still happens on the latest dev (38454c6).

    Steps:

    1. The model calls read with call ID read:0 on a.md.
    2. In the next turn it reuses read:0 to read b.md. The tool itself works fine.
    3. On the following prompt, OpenCode sends the whole history with the reused ID unchanged: two assistant tool calls and two tool results, all with ID read:0.

    A strict provider like NIM rejects that request, and because the duplicate is stored in the session history, every later request fails the same way, which matches what you saw. I couldn't trigger the provider's own Duplicate function_call_output error here without credentials, but the request OpenCode builds has exactly the duplicate it complains about. The script below reproduces it.

    Related issues:

    Until this is fixed, the only workaround I know of is to start a new session.

    Reproduction script (drive.ts)
    // Repro for #54588: model reuses tool-call ID "read:0" across turns;
    // the next request then carries two tool results for the same call ID.
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const requests: unknown[] = []
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        project: { git: true, files: { "a.md": "file A\n", "b.md": "file B\n" } },
        tui: { recording: true },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          yield* llm.serve((request, index) => {
            requests.push(request)
            switch (index) {
              case 0: // turn 1: read a.md with id read:0
                return Stream.make(
                  Llm.toolCall({ index: 0, id: "read:0", name: "read", input: { path: "a.md" } }),
                  Llm.finish("tool-calls"),
                )
              case 1:
                return Stream.make(Llm.text("Read A."))
              case 2: // turn 2: the model REUSES read:0 for a different file
                return Stream.make(
                  Llm.toolCall({ index: 0, id: "read:0", name: "read", input: { path: "b.md" } }),
                  Llm.finish("tool-calls"),
                )
              default:
                return Stream.make(Llm.text(`Done ${index}.`))
            }
          })
          yield* ui.submit("Read a.md")
          yield* ui.waitFor("Read A.")
          yield* ui.submit("Read b.md")
          yield* ui.waitFor("Done 3.")
          yield* ui.submit("Continue")
          yield* ui.waitFor("Done 4.")
          yield* ui.screenshot("final")
          const last = JSON.stringify(requests[requests.length - 1])
          const occurrences = (last.match(/read:0/g) ?? []).length
          yield* Effect.log(`LAST MESSAGES: ${JSON.stringify((requests[requests.length - 1] as any).body.messages.slice(1))}`)
          yield* Effect.log(`occurrences of "read:0" in last request: ${occurrences}`)
        }),
    )

    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.

  2. MounirMeziani commented on Oct 19, 2026

    @MounirMeziani

    Until ids are made unique at ingest, a session that's already stuck can be rescued by hand: export it, find the later assistant message that reuses read:0/read:1, and rename those ids in that tool call and in its matching tool result (e.g. read:1 → read:1-t617). Then re-import it. Each function_call then has exactly one function_call_output, and the provider accepts the history again. The durable fix is for the client to own uniqueness, e.g. rewrite incoming call ids to ${messageId}:${callId} when it stores them, because some providers emit ids that only stay unique within a single turn.

  3. JustCax commented on Oct 19, 2026

    @JustCax
    Author

    @MounirMeziani i Thanks for sharing this workaround! It sounds like a promising solution.

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