Skip to content

fix(engines): invoke opencode run and require file changes (#913) - #1008

Merged
frankbria merged 4 commits into
mainfrom
fix/913-opencode-run
Aug 1, 2026
Merged

fix(engines): invoke opencode run and require file changes (#913)#1008
frankbria merged 4 commits into
mainfrom
fix/913-opencode-run

Conversation

@frankbria

Copy link
Copy Markdown
Owner

Closes #913.

Problem

The adapter ran opencode --non-interactive with the prompt on stdin. No such flag exists. Verified against opencode 1.18.7:

  • --non-interactive is absent from the option list
  • passing it starts the TUI and prints usage rather than erroring
  • headless execution is opencode run [message..], with the message as a positional, not stdin

So the delegated run did no work. And because OpenCodeAdapter did not set require_file_changes=True, an exit-0 no-op was reported completed, gates ran on an unchanged tree, and the task could be marked DONE with no code written — the #739 false-completion class the claude-code adapter was already patched for.

Fix

Was Now
Invocation opencode --non-interactive opencode run <message>
Prompt stdin positional argument (get_stdin returns None, so it is not sent twice)
Zero-work exit 0 reported completed require_file_changes=Truefailed

--auto is off by default

I first added --auto on the reasoning that a headless coding agent needs permission auto-approval. Testing showed that is not true — a plain opencode run writes files fine under the default permission config. Since opencode documents --auto as "auto-approve permissions that are not explicitly denied (dangerous!)", and the prompt is derived from repository content, defaulting it on would add exactly the exposure #905#907 closed. It is now opt-in (OpenCodeAdapter(auto_approve=True)).

Where an operator's opencode config does deny writes, the run produces no file changes and require_file_changes turns that into a loud failure rather than a silent false completion — so declining the flag degrades safely.

Tests

Unit (tests/core/adapters/test_opencode.py) — the --non-interactive assertion is gone; new tests pin ["opencode", "run", prompt], get_stdin() is None, require_file_changes, and the --auto default.

The autouse fixture returned [] modified files, which under the new guard made three unrelated tests exercise the false-completion path instead of the behaviour they name. It now represents a run that did work, and the no-work case has its own tests (both directions: zero changes → not completed; changes → completed).

Binary-gated smoke (tests/core/adapters/test_opencode_smoke_913.py, criterion 4) — runs the adapter's own build_command output against the installed CLI, skipped when opencode is absent:

  • opencode run exists and --non-interactive does not — asserted against the CLI's own help, so it fails if a future release moves the headless entry point
  • the old invocation writes nothing (pins the bug, so nobody restores the flag believing it worked)
  • the adapter's command writes a real file
  • a run that writes nothing is not reported completed

The smoke test found a defect the issue does not cover — filed as #1007 [P0.27]

opencode ignores the subprocess cwd. Every SubprocessAdapter targets a workspace via cwd=workspace_path; opencode resolves its project directory from the parent process instead. Running the smoke test from the repo root, opencode reported Created \smoke.txt` and the file appeared in **/home/frankbria/projects/codeframe/`** — the pytest process's cwd — not the temp workspace. Reproduced twice.

Under codeframe serve that means a delegated task can edit CodeFRAME's own checkout while the task's workspace is untouched. require_file_changes does not save it: the guard inspects the workspace, finds nothing, and fails the run — correctly — while the edits have already landed elsewhere.

opencode run --dir <workspace> looks like the remedy, but two attempts to verify it timed out (240s, 280s) and wrote nothing — and a plain opencode run timed out immediately afterwards too, so the timeouts track opencode instability today, not --dir. I did not ship an unverified flag: assuming a CLI contract without checking is precisely what produced this issue. Filed as #1007 with the reproduction, and the smoke test xfails on exactly that condition so it converts into the regression test when #1007 is fixed.

Known limitation

The two model-dependent smoke tests skip on timeout rather than fail, so an upstream opencode outage does not red the suite. The CLI-contract assertions (help text, old invocation) are hard and deterministic.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 8m 4s —— View job


Precision bug-hunting review — PR #1008 (opencode run invocation)

  • Gather PR context (gh pr view, gh pr diff, existing comments)
  • Read codeframe/core/adapters/opencode.py + base SubprocessAdapter
  • Compare sibling ClaudeCodeAdapter (prompt via stdin) vs this PR (prompt via argv)
  • Trace prompt assembly (context_packager.py, context.py) for size limits
  • Verify require_file_changes / unborn-HEAD interaction in smoke fixture
  • Post confirmed defects as inline comments + summary table

Findings (2)

severity location defect
major codeframe/core/adapters/opencode.py:70 Prompt moved from stdin (unbounded) to a positional argv element. CodeFrame prompts routinely exceed Linux's ~128 KB MAX_ARG_STRLEN (PRD ≤10 KB + full relevant-file contents, within the 100K-token budget), so subprocess.Popen raises E2BIG → caught as OSErrorfailed on every large-prompt run. Regression vs the prior stdin path and the sibling ClaudeCodeAdapter.
minor tests/core/adapters/test_opencode_smoke_913.py:43 The repo fixture does git init with no initial commit → unborn HEAD → _git_head returns Nonein_git_repo is False → the require_file_changes guard never fires → the "writes nothing" smoke assertion fails on its intended success path. Fix: add an empty initial commit (suggestion posted inline).

Delivered as 2 inline comments (with a committable suggestion on the minor) + one summary comment. No code was modified; no builds/tests run. Did not approve or request changes.

Comment thread codeframe/core/adapters/opencode.py Outdated
Comment thread tests/core/adapters/test_opencode_smoke_913.py
@github-actions

Copy link
Copy Markdown
Contributor

GLM precision review — PR #1008

severity file:line finding
major codeframe/core/adapters/opencode.py:70 Prompt passed as a positional argv element; prompts > ~128 KB hit Linux MAX_ARG_STRLENPopen raises E2BIG → every large-prompt run returns failed. Regression vs the prior stdin transport and the sibling ClaudeCodeAdapter (still stdin).
minor tests/core/adapters/test_opencode_smoke_913.py:43 repo fixture has no initial commit → unborn HEAD → require_file_changes guard never fires → the "writes nothing" smoke assertion fails on its success path.

Scope: concrete defects only (logic / data-handling / regressions). Style, architecture, naming, and test coverage are covered by the general-purpose review and intentionally not repeated here.

View job run

…smoke fixture's unborn HEAD (#913)

Review feedback (both bot reviewers, independently):

[major] The prompt moved from stdin to a positional argv entry, but Linux caps
a single argv entry at MAX_ARG_STRLEN (128 KiB) while the context packager
budgets 100K tokens (~400 KB). Confirmed empirically: /bin/true with a 200 KB
argument raises OSError(E2BIG), so every large-prompt run would have failed
before opencode started. The positional stays for normal prompts (the form
verified end-to-end against the CLI); an oversized one is omitted from argv and
sent on stdin, which opencode reads when no positional is given — confirmed by
its own prompt_submit log carrying the piped text verbatim.

[minor] The smoke fixture's `git init` left an unborn HEAD, so _git_head
returned None, in_git_repo was False, and require_file_changes never fired —
the writes-nothing assertion was passing vacuously. Added a baseline commit.
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 3m 41s —— View job


Precision bug-hunting review — PR #1008 (opencode run invocation)

Synchronize event. Verified the two findings posted on the prior commit are resolved in ccc820c, then hunted for new concrete defects against the checked-out code.

  • Gather PR context (git diff origin/main...HEAD, prior comments)
  • Read current opencode.py (post-sync) + base SubprocessAdapter
  • Confirm prior findings (argv/E2BIG, unborn HEAD) are resolved in ccc820c
  • Hunt for new concrete defects vs. real callers/callees
  • Post findings

Prior findings — both resolved in ccc820c

prior finding status basis
major — prompt in argv → E2BIG on large prompts ✅ fixed opencode.py:88-100 now omits the prompt from argv when ≥ 128 KiB and pipes it via get_stdin(); build_command/get_stdin both key off the same _prompt_exceeds_argv, so the two decisions can't disagree. Threshold >= 128*1024 matches the kernel's len >= MAX_ARG_STRLEN check exactly (no off-by-one).
minor — smoke repo fixture had no commit → unborn HEAD → guard never fired ✅ fixed test_opencode_smoke_913.py:48-53 now makes an empty baseline commit.

New finding (1)

severity file:line finding
minor tests/core/adapters/test_opencode_smoke_913.py:133-141 except subprocess.TimeoutExpired wraps adapter.run(...), but SubprocessAdapter.run swallows TimeoutExpired internally and returns AgentResult(status="failed", error="Process timed out after …s") — it never propagates, so the skip is dead code and the documented "skip on timeout" never fires for this test.

Failure scenario: an opencode hang/outage during test_a_run_that_writes_nothing_is_not_reported_completed does not skip at 240 s. adapter.run() catches TimeoutExpired itself (subprocess_adapter.py:173-185) and returns failed, so the except on :140 is unreachable. The run blocks for the adapter default DEFAULT_TIMEOUT_S = 1800 (30 min) before returning failed, which then coincidentally satisfies result.status != "completed". The PR's stated known-limitation — "two model-dependent smoke tests skip on timeout… so an upstream opencode outage does not red the suite" — is only true for the other test (it calls subprocess.run directly on :87, which does raise). OpenCodeAdapter.__init__ also doesn't forward timeout_s, so the 240 s bound is unreachable through adapter.run(); the suggestion below fixes the dead handler — bounding it to 240 s additionally needs the adapter to accept timeout_s.

    result = adapter.run(
        "task-smoke",
        "Reply with the single word ACKNOWLEDGED. Do not create, edit or "
        "delete any files.",
        repo,
    )
    if (result.error or "").startswith("Process timed out"):
        pytest.skip("opencode did not complete in time")

Non-finding (noted, not a defect)

The large-prompt stdin path (opencode run with no positional, prompt piped) is exercised only by mocked unit tests; no binary-gated smoke test feeds an oversized prompt through adapter.run(). I could not verify opencode's actual stdin-with-no-positional behavior (binary not installed in this review env), and the PR asserts verification via the prompt_submit log — so per the precision mandate I am not posting it as a defect. Flagging only as a residual-contract risk the maintainers may want to cover with a smoke assertion analogous to the small-prompt one.

No production-code defects found in the synchronized changes; the one item above is test-only.

@frankbria

Copy link
Copy Markdown
Owner Author

Review feedback addressed (ccc820c)

Both reviewers independently reported the same two defects. Both verified against the real system before fixing, both confirmed, both fixed.

[major] Prompt in argv → E2BIG on large prompts — confirmed, fixed

Verified rather than assumed: Linux caps a single argv entry at MAX_ARG_STRLEN (32 pages = 131,072 bytes), independent of the 2 MB ARG_MAX.

$ python3 -c "subprocess.run(['/bin/true', 'x'*200_000])"
OSError: [Errno 7] Argument list too long

And codeframe/core/context.py:32 sets DEFAULT_MAX_TOKENS = 100_000 — roughly 400 KB of prompt. So the finding is real, not theoretical: any task with a substantial relevant-file context would have failed before opencode started.

Fix: the positional stays for normal prompts — that is the form verified end-to-end against the CLI, and I did not want to swap a working transport wholesale. An oversized prompt is omitted from argv and sent on stdin instead.

That stdin path is not an assumption either. opencode run with no positional reads the message from stdin — its own plugin log echoes the piped text back as the submitted query:

$ echo "Create a file stdin_probe.txt containing exactly: works" | opencode run
{"event":"prompt_submit","query":"Create a file stdin_probe.txt containing exactly: works\n"}

(That session then died on an upstream UnknownError — opencode's backend is erroring for me right now. The positional form fails identically at the moment, so the outage is general, not stdin-specific; the CLI-contract level is what is verifiable today, and both transports pass it.)

[minor] Smoke fixture's unborn HEAD — confirmed, fixed

Confirmed the mechanism end to end: git init with no commit leaves an unborn HEAD, git rev-parse HEAD fails, _git_head returns None, and subprocess_adapter.py:233 computes in_git_repo = head_after is not NoneFalse → the require_file_changes guard never fires. The "writes nothing" assertion was passing vacuously. Fixture now makes a baseline --allow-empty commit.

New tests

  • test_an_oversized_prompt_moves_to_stdin — 200 KB prompt leaves argv, still reaches opencode, and every argv entry is under the real 128 KiB limit
  • test_the_prompt_is_never_sent_twice — asserts in_argv XOR in_stdin for both a small and an oversized prompt, so neither transport can start duplicating the instruction

Demo — every acceptance criterion, with outcome evidence

AC Evidence
build_command produces opencode run <message> ['/home/frankbria/.opencode/bin/opencode', 'run', 'fix the failing test']; CLI help contains opencode run = True, contains --non-interactive = False
require_file_changes=True blocks a zero-work exit-0 run exit-0 binary writing nothing → status failed, modified_files: [], error "exited successfully but modified no files"
tests no longer assert --non-interactive grep-clean; the CLI-help assertion replaces it and fails if opencode ever adds the flag back
binary-gated smoke test against the real CLI 3 passed, 1 xfailed — the xfail is #1007 (opencode ignores the subprocess cwd), pinned deliberately

Plus the regression this round fixes, shown as an outcome rather than a claim:

prompt size            : 200,000 bytes
argv                   : ['.../opencode', 'run']      largest entry: 38 bytes
prompt routed to stdin : True
OLD (prompt in argv)   : OSError: Argument list too long
NEW (prompt on stdin)  : exec OK, no E2BIG

tests/core/adapters/ — 17 passed, 1 xfailed. ruff check clean.

…xcept (#913)

GLM review [minor]: SubprocessAdapter.run catches TimeoutExpired itself and
returns status='failed', so the smoke test's except was dead code — the
documented skip-on-timeout could never fire. Worse, OpenCodeAdapter never
forwarded timeout_s, so an opencode hang would sit on the 30-minute default
and then satisfy 'status != completed' for the wrong reason.

Forward timeout_s (same knob KilocodeAdapter already exposes), bound the smoke
run to 240s, and detect the timeout from the returned error instead.
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 4m 2s —— View job


Precision bug-hunting review — PR #1008 (opencode run invocation)

Synchronize event on 112035d. Verified the three prior-round findings are resolved in the checked-out code, then hunted for new concrete defects against the real callers/callees.

  • Gather PR context (gh pr diff, prior comments)
  • Read current opencode.py (post-sync) + base SubprocessAdapter
  • Confirm prior findings resolved in ccc820c / 112035d
  • Hunt for new concrete defects vs. real callers/callees
  • Post findings

Prior findings — all resolved

prior finding status basis (read in the checked-out tree)
major — prompt in argv → E2BIG on large prompts ✅ fixed opencode.py:70-105_prompt_exceeds_argv gates both build_command (omits prompt from argv when exceeded) and get_stdin (returns prompt when exceeded), keyed on the same len(prompt.encode()) >= 128*1024. The two transports cannot disagree. Boundary matches the kernel's MAX_ARG_STRLEN = 131072 (len > MAX_ARG_STRLEN fails) — at exactly 131072 the adapter routes to stdin, conservative, never under.
minor — smoke repo fixture had no commit → unborn HEAD → guard never fired ✅ fixed test_opencode_smoke_913.py:43-53git init then an --allow-empty baseline commit, so git rev-parse HEAD resolves and in_git_repo is True.
minor — dead except subprocess.TimeoutExpired (never propagates) ✅ fixed test_opencode_smoke_913.py:135-144 — constructs OpenCodeAdapter(timeout_s=_TIMEOUT_S) (the knob __init__ now forwards) and skips on (result.error or "").startswith("Process timed out").

New findings

✅ GLM review: no defects found.

Hunt covered the production diff and its real edges:

  • build_command / get_stdin consistency — both branch on _prompt_exceeds_argv; normal prompt → argv only (get_stdinNone, base class opens stdin=None), oversized → stdin only. No double-send, no neither-send.
  • Constructor contractauto_approve/timeout_s both defaulted; OpenCodeAdapter() still valid. Backward-compatible.
  • Registry wiringengine_registry.py:102 calls OpenCodeAdapter() with no kwargs, but the sole caller (runtime.py:785-788) builds adapter_kwargs={} for every non-cloud engine, so nothing is silently dropped — symmetric with how kilocode/claude-code are actually invoked. Not a defect.
  • E2BIG boundary vs. kernel_MAX_ARG_BYTES = 128*1024 equals MAX_ARG_STRLEN on 4 KiB-page systems (the target); on 64 KiB-page kernels the real cap is larger, so routing to stdin at 128 KiB is merely conservative. No under-bound.
  • require_file_changes=True guard path — exercised both directions in TestZeroWorkIsNotCompleted; the autouse _no_git fixture now returns a modified file + a stable HEAD, so the unrelated tests no longer trip the false-completion path. Sound.

Non-finding (unchanged from last round, not a defect): the oversized-prompt → stdin transport has no binary-gated smoke coverage (only mocked unit tests). Per the precision mandate I will not assert unverified CLI behavior; #915 is the right home for it.

No production-code or test defects in the synchronized changes.

@frankbria

Copy link
Copy Markdown
Owner Author

Third round addressed (112035d)

[minor] dead except subprocess.TimeoutExpired — confirmed, fixed. Verified rather than taken on faith: subprocess_adapter.py:174-185 catches TimeoutExpired and returns AgentResult(status="failed", error="Process timed out after …s"), so it never propagates and the handler was unreachable.

The reviewer's second-order point is the one that actually bites, and it was right: OpenCodeAdapter.__init__ never forwarded timeout_s, so the 240 s bound was unreachable through adapter.run() and a hang would sit on DEFAULT_TIMEOUT_S (30 min) before returning failed — which then satisfies status != "completed" for the wrong reason. That is a test that passes without testing anything, which is the exact class #915 exists to close.

Fix: forward timeout_s (the same knob KilocodeAdapter already exposes), construct the smoke adapter with timeout_s=_TIMEOUT_S, and detect the timeout from the returned error.

On the noted non-finding (stdin path has no binary-gated coverage)

Fair, and I am recording it as a limitation rather than papering over it. opencode's backend is returning UnknownError for every run on this machine right now — the positional form fails identically, so it is a general outage, not something about the stdin path. That makes an oversized-prompt end-to-end smoke assertion unwriteable-and-verifiable today; adding one I could not run would be the same mistake that produced #913. The stdin transport's evidence stands at CLI-contract level: opencode's own prompt_submit log echoes the piped text back as the submitted query.

The natural home for that assertion is #915 (P0.21), which builds the binary-gated tier across all engines and is blocked by this PR.

tests/core/adapters/ — 17 passed, 1 xfailed (#1007). ruff check clean.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 2m 23s —— View job


Precision bug-hunting review — PR #1008 (opencode run invocation)

Synchronize event on 33085ba (merge main into fix/913-opencode-run). Verified the merge did not touch the opencode adapter or any file it depends on, then re-hunted for concrete defects against the checked-out tree.

  • Gather PR context (gh pr view, gh pr diff, prior comments)
  • Inspect the merge commit (33085ba) — what changed vs. prior 112035d
  • Read current opencode.py + base SubprocessAdapter (post-merge)
  • Confirm prior findings resolved; check merge did not regress them
  • Hunt for new concrete defects vs. real callers/callees
  • Post findings

What this sync actually changed

git log 112035d..HEAD is exactly two commits: the Codex adapter rewrite 3628c1d (#914, landed on main under #1009) and the merge commit itself. The full --name-only diff is only codex.py, test_codex.py, the codex_app_server/ fixtures, pyproject.toml, and uv.lock.

The opencode path is byte-identical to the commit already reviewed clean across three prior rounds:

opencode-transitive file changed by merge?
codeframe/core/adapters/opencode.py ✅ no (git diff 112035d..HEAD empty)
codeframe/core/adapters/subprocess_adapter.py ✅ no
codeframe/core/adapters/agent_adapter.py ✅ no
codeframe/core/adapters/git_utils.py ✅ no
codeframe/core/blocker_detection.py ✅ no
codeframe/core/engine_registry.py ✅ no
codeframe/core/runtime.py ✅ no

No conflict markers in any opencode file or its tests. The Codex rewrite is self-contained in codex.py (the app-server protocol adapter, which does not subclass SubprocessAdapter) and therefore shares no execution path with opencode.

Prior findings — all still resolved (unchanged code carries the prior fixes forward)

prior finding status basis
major — prompt in argv → E2BIG on large prompts ✅ fixed opencode.py:70-105_prompt_exceeds_argv gates both build_command and get_stdin on the same len(prompt.encode()) >= 128*1024; oversized prompt omitted from argv and piped via stdin. No double-send, no neither-send.
minor — smoke repo fixture unborn HEAD → guard never fired ✅ fixed baseline --allow-empty commit present.
minor — dead except subprocess.TimeoutExpired ✅ fixed OpenCodeAdapter(timeout_s=...) is forwarded; timeout detected from the returned error string.

New findings

✅ GLM review: no defects found.

Re-hunted the opencode diff and its real edges against the checked-out tree:

  • Base-class contractbuild_command reads self._binary_path / self._cli_args (both set in SubprocessAdapter.__init__:55-66); get_stdin returns str | None, consumed as stdin=PIPE if stdin_content else None (subprocess_adapter.py:119). Overrides type-check against the real base.
  • E2BIG boundary_MAX_ARG_BYTES = 128*1024 and the kernel fails a single arg at len > MAX_ARG_STRLEN (131072). The adapter routes to stdin at len >= 131072, so the largest argv entry ever sent is 131071 bytes — strictly under the cap. No off-by-one that could ship an oversized prompt.
  • require_file_changes=True guard — exercised both directions; head_before is read only when _require_file_changes (subprocess_adapter.py:109-111), and the committed/in_git_repo logic cannot credit a None != sha transition as work (:239-243). Sound.

The merged-in Codex rewrite is out of scope for this PR — it is the subject of a separate, already-landed change (#1009) and was reviewed there. I did not re-review it here, consistent with this PR's scope (the opencode invocation fix).

No production-code or test defects in the synchronized opencode changes.

@frankbria
frankbria merged commit b682ee9 into main Aug 1, 2026
16 of 17 checks passed
@frankbria
frankbria deleted the fix/913-opencode-run branch August 1, 2026 04:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P0.19] Invoke opencode run and require file changes for the OpenCode engine

1 participant