Skip to content

cli: opencode run --format json drops the final step_finish event #53207

Closed

Description

@nkootstra

Description

opencode run --format json usually exits without printing the final step_finish event, even though the session completed and stored that step. Scripts that read tokens and cost from step_finish lose usage for the final step.

With OpenCode Go, 9 of 9 completed runs ended like this:

step_start tool_use step_finish step_start text

The final step_finish is missing. opencode session export shows the final assistant message with finish: "stop" and the expected text. The same thing happens with no tool call (step_start text, no step_finish).

In packages/cli/src/run/noninteractive.ts, session.wait resolves when the session goes idle. The client then sets finalizing = true, which drops every queued event except session.execution.*. reconcile() fills in missing text, reasoning and tool parts from the saved messages, but never emits step_start or step_finish. If idle arrives before the final session.step.ended is processed, that event is lost. The existing test uses session.wait then reconciles projected output without a terminal event expects only text in this case.

Older reports of this symptom (#31435, #38661, #26855) were for the v1 run.ts and were closed. This is the v2 client.

Plugins

None relevant (local opencode-sync and tapas plugins installed)

OpenCode version

v2.0.22, also reproduced from source on v2 at 0a46301

Steps to reproduce

for i in 1 2 3 4 5; do
  opencode run --auto --format json -m opencode-go/deepseek-v4-flash \
    "Use the bash tool to run: echo tool-ok, then reply with exactly the command's output" \
    | jq -r .type | tr '\n' ' '; echo
done

Expected: each line ends with step_start text step_finish.
Actual: the final step_finish is missing (5/5 with deepseek-v4-flash, 4/4 completed with glm-5.3-flash).

Check the session:

opencode session export <sessionID> | jq -c '.messages[] | select(.type=="assistant") | {finish}'
# {"finish":"tool-calls"}
# {"finish":"stop"}

Screenshot and/or share link

No response

Operating System

macOS 26.5

Terminal

Warp v0.2026.09.02.08.27.stable_01

Activity

  1. github-actions commented on Oct 4, 2026

    @github-actions
    Contributor

    This issue might be related to earlier reports. Please check:

    These reports were for v1; this issue describes the v2 client.

  2. changed the title [-]`opencode run --format json` drops the final `step_finish` event[/-] [+]opencode run --format json drops the final step_finish event[/+] on Oct 5, 2026
  3. changed the title [-]opencode run --format json drops the final step_finish event[/-] [+]cli: opencode run --format json drops the final step_finish event[/+] on Oct 5, 2026
  4. oldantique commented on Oct 12, 2026

    @oldantique

    Still reproduces on v2.0.26 (9b4ec57), Linux x86_64, deepseek/deepseek-v4-flash: 8/8 --format json runs ended on text with no final step_finish (intermediate steps do get step_finish with tool-calls). Filed #54661 before finding this one — closing it as a duplicate.

  5. mp-grind commented on Oct 22, 2026

    @mp-grind

    Just ran into this as well. Thanks for digging into it for the PR. Looking forward to seeing it get merged.

  6. veyomr commented on Oct 27, 2026

    @veyomr

    Still on 2.1.4 (@opencode/cli-linux-x64) with openrouter/minimax/minimax-m3.1-flash, run --standalone --format json: final step_finish missing in 7 of 7 runs, 1 to 110 steps. Details in #59652.

  7. opencodejr commented on Oct 27, 2026

    @opencodejr

    Thanks for the detailed report and for pointing to the code. I reproduced this without a real provider, using a local mock OpenAI-compatible model that first asks to run bash and then replies with text, with token usage on every response.

    • On v2.0.22, opencode run --standalone --auto --format json -m mock/m "..." | jq -r .type printed step_start tool_use step_finish step_start text in every run, with no final step_finish. Text-only replies printed just step_start text.
    • It still happens on the latest dev (fd8268ea5), run from source: the same output in every run, with and without a tool call.

    This matches your analysis. Once session.wait resolves, the client stops handling queued step events, and reconcile() only fills in text, reasoning and tool output. So the final step's finish reason, tokens and cost never reach the stream.

    The script below reproduces it, with the mock server included. #54661 and #59652 report the same problem, and the open PR #53208 targets this issue.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces anomalyco/opencode#53207: `opencode run --format json` omits the final step_finish.
    # Uses an embedded mock OpenAI-compatible server (first a bash tool call, then text "tool-ok"; MOCK_TOOL=0 -> text only).
    # Needs bun, jq, git.
    # Usage: ./repro.sh [runs]   (set OPENCODE="bun ... packages/cli/src/index.ts" to run from source)
    set -u
    DIR="$(mktemp -d /tmp/oc-mock.XXXXXX)"
    cat > "$DIR/mock.ts" <<'TS'
    // Mock OpenAI-compatible chat completions server (streaming).
    // Request with no tool result yet -> bash tool call; otherwise -> text "tool-ok".
    const port = Number(process.env.MOCK_PORT ?? 4599)
    const sse = (chunks: unknown[]) =>
      chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join("") + "data: [DONE]\n\n"
    Bun.serve({
      port,
      async fetch(req) {
        const body: any = await req.json().catch(() => ({}))
        const hasTool = (body.messages ?? []).some((m: any) => m.role === "tool")
        const wantTool = process.env.MOCK_TOOL !== "0" && !hasTool
        const base = { id: "c1", object: "chat.completion.chunk", created: 0, model: "mock" }
        const usage = { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
        const chunks = wantTool
          ? [
              { ...base, choices: [{ index: 0, delta: { role: "assistant", tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "bash", arguments: "" } }] } }] },
              { ...base, choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify({ command: "echo tool-ok", description: "echo" }) } }] } }] },
              { ...base, choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] },
              { ...base, choices: [], usage },
            ]
          : [
              { ...base, choices: [{ index: 0, delta: { role: "assistant", content: "tool-ok" } }] },
              { ...base, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] },
              { ...base, choices: [], usage },
            ]
        return new Response(sse(chunks), { headers: { "content-type": "text/event-stream" } })
      },
    })
    console.log("mock on", port)
    TS
    RUNS="${1:-5}"
    OC="${OPENCODE:-opencode}"
    PROJ="$(mktemp -d /tmp/opencode/proj.XXXXXX)"; cd "$PROJ" && git init -q
    cat > opencode.json <<'JSON'
    { "providers": { "mock": { "name": "Mock", "env": ["MOCK_API_KEY"],
      "package": "@opencode/ai/providers/openai-compatible",
      "settings": { "baseURL": "http://127.0.0.1:4599/v1" },
      "models": { "m": { "name": "Mock" } } } } }
    JSON
    export MOCK_API_KEY=x
    for tool in 1 0; do
      MOCK_TOOL=$tool bun "$DIR/mock.ts" & MOCK=$!; sleep 1
      echo "== tool call: $tool"
      for i in $(seq "$RUNS"); do
        $OC run --standalone --auto --format json -m mock/m "Use bash to echo tool-ok, then reply with its output" \
          | jq -r .type | tr '\n' ' '; echo
      done
      kill $MOCK; wait $MOCK 2>/dev/null
    done

    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions