-
Notifications
You must be signed in to change notification settings - Fork 5
fix(engines): invoke opencode run and require file changes (#913) #1008
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,143 @@ | ||||||||||||||||||||||
| """Binary-gated smoke test: the OpenCode adapter against the real CLI (#913). | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| The adapter shipped invoking ``opencode --non-interactive`` with the prompt on | ||||||||||||||||||||||
| stdin. **No such flag exists.** Verified against opencode 1.18.7: it is absent | ||||||||||||||||||||||
| from the option list, and passing it starts the TUI — so the delegated run did | ||||||||||||||||||||||
| no work, exited non-zero on the help path, and unit tests built entirely from | ||||||||||||||||||||||
| mocks could not tell. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| That is the gap this file closes. Every assertion here runs the adapter's own | ||||||||||||||||||||||
| ``build_command`` output against the installed binary, so a future change to | ||||||||||||||||||||||
| the invocation that unit tests would happily mock is caught here instead. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Skipped when ``opencode`` is not installed, so CI without the binary stays | ||||||||||||||||||||||
| green — but it is **launch-gating**, not deferred: the engine is one of the | ||||||||||||||||||||||
| shipped multi-model options. | ||||||||||||||||||||||
| """ | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import shutil | ||||||||||||||||||||||
| import subprocess | ||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import pytest | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| from codeframe.core.adapters.opencode import OpenCodeAdapter | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| pytestmark = [ | ||||||||||||||||||||||
| pytest.mark.v2, | ||||||||||||||||||||||
| pytest.mark.skipif( | ||||||||||||||||||||||
| shutil.which("opencode") is None, reason="opencode CLI not installed" | ||||||||||||||||||||||
| ), | ||||||||||||||||||||||
| ] | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| #: Long enough for a real model round-trip, short enough to fail a hang loudly. | ||||||||||||||||||||||
| _TIMEOUT_S = 240 | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| @pytest.fixture | ||||||||||||||||||||||
| def repo(tmp_path: Path) -> Path: | ||||||||||||||||||||||
| workspace = tmp_path / "repo" | ||||||||||||||||||||||
| workspace.mkdir() | ||||||||||||||||||||||
| subprocess.run(["git", "init", "-q"], cwd=workspace, check=True) | ||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [minor] The Failure scenario: with no commit,
Suggested change
|
||||||||||||||||||||||
| return workspace | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def test_the_run_subcommand_exists_and_non_interactive_does_not() -> None: | ||||||||||||||||||||||
| """The assumption that produced the bug, checked against the binary. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Asserted on the CLI's own help rather than on our code, so this fails if a | ||||||||||||||||||||||
| future opencode release moves the headless entry point out from under us. | ||||||||||||||||||||||
| """ | ||||||||||||||||||||||
| proc = subprocess.run( | ||||||||||||||||||||||
| ["opencode", "--help"], capture_output=True, text=True, timeout=60 | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| # opencode prints help on stderr, not stdout. | ||||||||||||||||||||||
| help_text = proc.stdout + proc.stderr | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| assert "opencode run" in help_text, "the headless entry point moved" | ||||||||||||||||||||||
| assert "--non-interactive" not in help_text, ( | ||||||||||||||||||||||
| "--non-interactive now exists; revisit the adapter's invocation" | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def test_the_adapter_command_actually_writes_a_file(repo: Path) -> None: | ||||||||||||||||||||||
| """Runs exactly what the adapter builds, and asserts on the file it produces. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Not "exit 0" — the old invocation could exit 0 and do nothing, which is the | ||||||||||||||||||||||
| whole false-completion class here. | ||||||||||||||||||||||
| """ | ||||||||||||||||||||||
| adapter = OpenCodeAdapter() | ||||||||||||||||||||||
| cmd = adapter.build_command( | ||||||||||||||||||||||
| "Create a file named smoke.txt containing exactly the word: works", repo | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| try: | ||||||||||||||||||||||
| proc = subprocess.run( | ||||||||||||||||||||||
| cmd, cwd=repo, capture_output=True, text=True, timeout=_TIMEOUT_S | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| except subprocess.TimeoutExpired: | ||||||||||||||||||||||
| pytest.skip(f"opencode did not complete within {_TIMEOUT_S}s") | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| produced = repo / "smoke.txt" | ||||||||||||||||||||||
| if not produced.exists() and "smoke.txt" in proc.stdout: | ||||||||||||||||||||||
| # opencode reported writing the file but it is not in the workspace — | ||||||||||||||||||||||
| # it resolves its project directory from the *parent* process, ignoring | ||||||||||||||||||||||
| # the subprocess cwd every other adapter relies on. Tracked as | ||||||||||||||||||||||
| # #1007 [P0.27]; distinct from this issue's contract. | ||||||||||||||||||||||
| pytest.xfail("opencode ignored cwd and wrote outside the workspace") | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| assert produced.exists(), ( | ||||||||||||||||||||||
| f"the adapter's command wrote nothing.\n" | ||||||||||||||||||||||
| f"cmd={cmd}\nstdout={proc.stdout[-2000:]}\nstderr={proc.stderr[-2000:]}" | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| assert "works" in produced.read_text() | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def test_the_old_invocation_does_no_work(repo: Path) -> None: | ||||||||||||||||||||||
| """Pins the actual bug, so nobody restores the flag believing it worked. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ``opencode --non-interactive`` prints the banner/usage and writes nothing. | ||||||||||||||||||||||
| """ | ||||||||||||||||||||||
| proc = subprocess.run( | ||||||||||||||||||||||
| ["opencode", "--non-interactive"], | ||||||||||||||||||||||
| cwd=repo, | ||||||||||||||||||||||
| input="Create a file named old.txt containing: works", | ||||||||||||||||||||||
| capture_output=True, | ||||||||||||||||||||||
| text=True, | ||||||||||||||||||||||
| timeout=60, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| assert not (repo / "old.txt").exists(), ( | ||||||||||||||||||||||
| "the old invocation wrote a file; the premise of #913 no longer holds" | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| assert proc.returncode != 0 or "Commands:" in proc.stdout | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def test_a_run_that_writes_nothing_is_not_reported_completed(repo: Path) -> None: | ||||||||||||||||||||||
| """End to end through `adapter.run`, in a real git repo, with a prompt that | ||||||||||||||||||||||
| asks for no file changes — the guard must catch it.""" | ||||||||||||||||||||||
| adapter = OpenCodeAdapter() | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| try: | ||||||||||||||||||||||
| result = adapter.run( | ||||||||||||||||||||||
| "task-smoke", | ||||||||||||||||||||||
| "Reply with the single word ACKNOWLEDGED. Do not create, edit or " | ||||||||||||||||||||||
| "delete any files.", | ||||||||||||||||||||||
| repo, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| except subprocess.TimeoutExpired: | ||||||||||||||||||||||
| pytest.skip("opencode did not complete in time") | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| changed = subprocess.run( | ||||||||||||||||||||||
| ["git", "status", "--porcelain"], cwd=repo, capture_output=True, text=True | ||||||||||||||||||||||
| ).stdout.strip() | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if changed: | ||||||||||||||||||||||
| pytest.skip(f"the model wrote files anyway: {changed!r}") | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| assert result.status != "completed", ( | ||||||||||||||||||||||
| f"a run that wrote nothing was reported {result.status!r} — gates would " | ||||||||||||||||||||||
| f"then pass on an unchanged tree" | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[major] The task prompt is passed as a positional argv element, but CodeFrame prompts routinely exceed the OS single-argument limit (~128 KB on Linux), so large-prompt
opencoderuns fail at process start.Failure scenario:
TaskContextPackager.buildassembles the prompt fromto_prompt_context()— up to 10 KB of PRD (context.py:179) plus the full contents of every relevant file (context.py:230, each capped atDEFAULT_FILE_TOKENS * CHARS_PER_TOKEN), within a 100K-token budget. A prompt > ~131072 bytes hits Linux'sMAX_ARG_STRLEN, soexecvereturnsE2BIGandsubprocess.PopenraisesOSError: [Errno 7] Argument list too long.SubprocessAdapter.runcatches it (subprocess_adapter.py:203) and returnsAgentResult(status="failed", error="Failed to start 'opencode': ...")on every run. The prior stdin transport (this PR's oldget_stdinreturned the prompt) and the siblingClaudeCodeAdapter(still stdin) have no such cap — this is a regression for the normal large-prompt task, not an edge case.Fix needs CLI verification (the discipline this PR correctly followed, so I am not asserting it here): confirm whether
opencode runwith no positional reads the message from stdin, or accepts a prompt-file, and pipe the prompt that way; otherwise explicitly guard oversize prompts. I deliberately did not write a suggestion that assumes stdin support — that is exactly the unverified-contract mistake this issue exists to stop.