Repository navigation
Description
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
- 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
- From inside the repo, ask the model to read a path outside the project root
(which triggers theexternal_directorypermission auto-reject):opencode run "Read this EXACT absolute path and output ONLY its contents verbatim, nothing else: /etc/hostname" - 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-flashand the defaultdeepseek-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 jsonincludes a tool part with the rejection:and the stream ends on{"type":"tool_use","part":{"tool":"read","state":{"status":"error", "input":{"filePath":"/etc/hostname"}, "error":"The user rejected permission to use this specific tool call."}}}step_finish(reasontool-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 jsonand check for tool parts
withstate.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
- opencode run produces no stdout output while running #22243 —
opencode runproduces no stdout output while running (same
silent-failure family: missing output / no error surfaced; this issue is
specifically about the exit-code + stdout contract on tool-call rejection).
Activity
github-actions commentedon Jul 11, 2026
This issue might be a duplicate of or closely related to an existing issue. Please check:
You may want to coordinate with or add context to that issue, or a maintainer may choose to consolidate them.
oldantique commentedon Jul 11, 2026
Checked #32633 — related, but a different failure class; noting the distinction for triage:
1with human-readable stderr — the ask there is to make which failure machine-readable.0with empty stdout — the failure is indistinguishable from success, not merely undifferentiated.Both point at the same underlying gap:
opencode runhas 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 exit0. Happy to fold this into #32633 instead if that's the preferred home.chernistry commentedon Aug 12, 2026
Second data point, from an orchestrator that drives
opencode runnon-interactively.We see the same exit 0, and the path looks structural rather than incidental:
erroris assigned only from
session.error(run.ts:776-786) and read at run.ts:837, whilethe 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 jsonstream has noterminal 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.
oldantique commentedon Aug 12, 2026
Thanks — the run.ts read matches what I see at HEAD, and the auto-reject branch never touching
errorexplains 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 exactlytool_use,step_start,step_finish,text,reasoning,error; the loop then breaks onsession.status == idleand 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 jsonand 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.
chernistry commentedon Sep 2, 2026
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.tshas moved since —08faeb3(#43675) reworked the samepermission.askedbranch so subagent permissions get answered — and both gaps survived that change:erroris still assigned only inside thesession.errorbranch (run.ts:781-789). The auto-reject branch is run.ts:801-820 and never touches it.finish()setsprocess.exitCode = 1only when thaterroris set (run.ts:839-843); the two other exit-1 paths (run.ts:855, run.ts:872) areresult.errorfromsession.command/session.prompt, which a rejected tool call does not produce.emit()still covers exactlytool_use,step_start,step_finish,text,reasoning,error(run.ts:678-690), and the loop still breaks onsession.statusidle 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 runnon-interactively with--format json, so happy to test a patch here as well.oldantique commentedon Sep 2, 2026
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 jsonrun 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
chernistry commentedon Sep 3, 2026
Following along: the terminal
doneevent 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 whatrun-process.test.tsasserts 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.oldantique commentedon Sep 21, 2026
Opened #50341 for the terminal event. Local
--format jsonruns 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 onstep_finishstopdrops tool continuations, and the PR has a test showing it.oldantique commentedon Oct 11, 2026
Following the V2 redirect on #50341, I checked
devat 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_finishand error records, but no explicit run-level completion record.Would you accept a small V2 PR adding a final JSON-only
donerecord inpackages/cli/src/run/noninteractive.ts, after waiting for idle and reconciling output? It would includetimestamp,sessionID, andrejectedaction names (e.g.["external_directory"]), with tests. Default output and exit codes would stay unchanged.thdxr commentedon Oct 11, 2026
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 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊