Skip to content

opencode run exits 0 with empty stdout when a tool call is auto-rejected and the model produces no final message #36413

Closed as not planned

Description

@oldantique

Summary

In non-interactive mode (opencode run), if a tool call is auto-rejected by the
permission system and the model then produces no final assistant text, the process
exits with code 0 and prints nothing to stdout. There is no machine-detectable
signal on the two channels automation normally relies on (exit code and stdout), so
a run that was blocked and answered nothing is indistinguishable from a run that
legitimately produced an empty answer.

Environment

  • opencode 1.17.15
  • Linux (x86_64)
  • Provider: openai-compatible endpoint, DeepSeek (models deepseek/deepseek-v4-pro
    default, deepseek/deepseek-v4-flash)

Steps to reproduce

  1. Create a git repo so there is a clear project root:
    mkdir /tmp/oc-silent && cd /tmp/oc-silent && git init && echo "# test" > README.md
  2. From inside the repo, ask the model to read a path outside the project root
    (which triggers the external_directory permission auto-reject):
    opencode run "Read this EXACT absolute path and output ONLY its contents verbatim, nothing else: /etc/hostname"
  3. Inspect the exit code and stdout:
    echo "exit=$?"        # -> 0

Observed

  • Exit code: 0
  • stdout: 0 bytes (empty)
  • The model attempts the read, it is auto-rejected, and the model returns no final
    text, so nothing is printed.
  • Reproduced 5/5 times across deepseek-v4-flash and the default deepseek-v4-pro.

The only indications anything went wrong are:

  • stderr prints (interleaved with normal progress logging):
    > build · deepseek-v4-flash
    ! permission requested: external_directory (/etc/*); auto-rejecting
    ✗ Read /etc/hostname failed
    Error: The user rejected permission to use this specific tool call.
    
  • --format json includes a tool part with the rejection:
    {"type":"tool_use","part":{"tool":"read","state":{"status":"error",
     "input":{"filePath":"/etc/hostname"},
     "error":"The user rejected permission to use this specific tool call."}}}
    and the stream ends on step_finish (reason tool-calls) with no assistant
    text part.

Expected

At least one reliable, documented signal that the run did not complete normally.
Any of:

  • A non-zero exit code when a run ends with no assistant output because a tool call
    was rejected/errored, or
  • In plain-text mode, the rejection/error surfaced on stdout (or a documented
    stderr contract) rather than only as interleaved log lines, or
  • Documentation that automation must use --format json and check for tool parts
    with state.status == "error" / a missing final text part.

Why it matters

Headless automation drives opencode run and consumes exit code + stdout. Today a
run that was blocked by a permission rejection and produced no answer looks
identical to a successful empty answer: exit 0, empty stdout. There is no way to
tell "rejected and said nothing" from "legitimately returned nothing" without
opting into JSON output and parsing tool-part states, or scraping unstructured
stderr. The default contract silently swallows the failure.

Related

Activity

  1. github-actions commented on Jul 11, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of or closely related to an existing issue. Please check:

    • [FEATURE]: CLI: expose machine-readable signal for provider-side retryable failures #32633: [FEATURE]: CLI: expose machine-readable signal for provider-side retryable failures — requests the same outcome (machine-readable exit codes / structured error output for automation) from a slightly different angle (provider-side failures vs. permission-rejection failures). The expected behavior you describe overlaps substantially with the feature requested there.

    You may want to coordinate with or add context to that issue, or a maintainer may choose to consolidate them.

  2. oldantique commented on Jul 11, 2026

    @oldantique
    Author

    Checked #32633 — related, but a different failure class; noting the distinction for triage:

    Both point at the same underlying gap: opencode run has no documented machine-readable failure contract for automation. If the maintainers prefer to consolidate, a unified contract (distinct exit codes and/or a structured error record, as proposed in #32633) would resolve this issue too — provided "tool call rejected, no assistant output" is included as one of the enumerated failure classes and stops mapping to exit 0. Happy to fold this into #32633 instead if that's the preferred home.

  3. chernistry commented on Aug 12, 2026

    @chernistry

    Second data point, from an orchestrator that drives opencode run non-interactively.

    We see the same exit 0, and the path looks structural rather than incidental: error
    is assigned only from session.error (run.ts:776-786) and read at run.ts:837, while
    the permission auto-reject branch (run.ts:796-815) never sets it — so a denied tool
    call cannot reach the exit code by construction.

    One thing worth adding to the enumeration here: the --format json stream has no
    terminal event either. The loop breaks on session idle (run.ts:789-793) without
    emitting, so the stream simply stops and that channel can't separate "blocked" from
    "finished" either.

  4. oldantique commented on Aug 12, 2026

    @oldantique
    Author

    Thanks — the run.ts read matches what I see at HEAD, and the auto-reject branch never touching error explains why the exit 0 was 5/5 rather than intermittent. It isn't flaky, it's wired shut.

    One refinement on the JSON channel, because I think the gap there is different from "can't separate blocked from finished" — and worse. A rejection is observable: the tool part is emitted with state.status == "error" and the rejection message, which is the workaround I'd been relying on. What's missing is a completion marker. emit() covers exactly tool_use, step_start, step_finish, text, reasoning, error; the loop then breaks on session.status == idle and returns without emitting. So the tail of a normally-finished stream is byte-identical to one whose process was killed, hit a timeout, or died mid-write.

    That makes the third option in my original "Expected" list weaker than I wrote it: documenting "use --format json and inspect tool part states" does let you detect a rejection, but it still leaves a consumer unable to tell a complete run from a truncated one. So the ask narrows to the first two — a non-zero exit when a run ends with no assistant output because a tool was rejected, or a terminal event on the JSON stream (session.idle / done, with a reason). The latter fixes both at once and is the one I'd prefer.

    Happy to test a patch against a DeepSeek-backed provider if someone puts one up.

  5. chernistry commented on Sep 2, 2026

    @chernistry

    Your correction is right, and it narrows what I wrote: the rejection is observable on the JSON stream, the completion is not.

    Re-checked at dev (82b6650). packages/opencode/src/cli/cmd/run.ts has moved since — 08faeb3 (#43675) reworked the same permission.asked branch so subagent permissions get answered — and both gaps survived that change:

    • error is still assigned only inside the session.error branch (run.ts:781-789). The auto-reject branch is run.ts:801-820 and never touches it. finish() sets process.exitCode = 1 only when that error is set (run.ts:839-843); the two other exit-1 paths (run.ts:855, run.ts:872) are result.error from session.command / session.prompt, which a rejected tool call does not produce.
    • emit() still covers exactly tool_use, step_start, step_finish, text, reasoning, error (run.ts:678-690), and the loop still breaks on session.status idle at run.ts:794-798 and returns without emitting anything.

    So the reproduction still holds at HEAD, and the tail of a normally-finished stream is still byte-identical to a truncated one. Agreed that the terminal event is the better of the two asks, since it closes the completion gap for every consumer rather than only for the one that reads exit codes. We drive opencode run non-interactively with --format json, so happy to test a patch here as well.

  6. oldantique commented on Sep 2, 2026

    @oldantique
    Author

    Happy to put up a PR for the terminal event if that's welcome — roughly: emit a final {"type":"done","sessionID":…,"reason":"idle"} record before the idle break in run.ts, plus a run-process test asserting the last line of a --format json run is that record. #44966 is reworking the same exit path for #44901, so I'd rebase on whatever lands there.

    One question before touching exit codes: run-process.test.ts currently asserts exit 0 and empty stdout for an auto-rejected permission ("rejects requested permissions by default"), so that half reads as intended behavior rather than a bug. Should the PR leave it alone and only add the event, or is nonzero on auto-reject wanted too? @kitlangton

  7. chernistry commented on Sep 3, 2026

    @chernistry

    Following along: the terminal done event is the part that matters most from the consumer side. It closes the gap for anyone reading --format json, whatever the exit-code policy ends up being. Whether auto-reject should exit non-zero is really a call about what run-process.test.ts asserts as intended behaviour. A documented distinction between "ran and produced no output" and "was blocked before producing output" would be enough for us even if the exit code stays 0.

  8. oldantique commented on Sep 21, 2026

    @oldantique
    Author

    Opened #50341 for the terminal event. Local --format json runs now end with {"type":"done","reason":"idle","rejected":[...]}, which lists any auto-rejected permissions. That should cover the "ran and produced nothing" vs "was blocked" distinction @chernistry asked for. Exit codes are unchanged. I didn't build on #44966: breaking on step_finish stop drops tool continuations, and the PR has a test showing it.

  9. oldantique commented on Oct 11, 2026

    @oldantique
    Author

    Following the V2 redirect on #50341, I checked dev at 0d61729. V2 rejects non-interactive permission requests with feedback so the model can continue. If it then stops without text, default-format stdout can still be empty with exit 0.

    JSON already includes step_finish and error records, but no explicit run-level completion record.

    Would you accept a small V2 PR adding a final JSON-only done record in packages/cli/src/run/noninteractive.ts, after waiting for idle and reconciling output? It would include timestamp, sessionID, and rejected action names (e.g. ["external_directory"]), with tests. Default output and exit codes would stay unchanged.

  10. thdxr commented on Oct 11, 2026

    @thdxr
    Member

    Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.

    — from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions