From 3307386ea80c8d3a9b34b235005e4444a3bd687e Mon Sep 17 00:00:00 2001 From: Luke Inglis Date: Wed, 27 May 2026 11:26:38 -0400 Subject: [PATCH 1/3] feat: add Qwen Code runner support Add QwenRunner as a fourth CLI backend alongside Claude Code, Bob Shell, and Codex. Qwen Code's CLI flags mirror Claude Code (--append-system-prompt, -p, --model), making it the simplest runner addition. Key details: - Auth: warns if DASHSCOPE_API_KEY/QWEN_API_KEY missing (non-blocking) - Headless: uses --yolo and --output-format text - Dry-run: FACTORY_QWEN_DRY_RUN=1 returns stub responses - Env: strips VIRTUAL_ENV from subprocess environment - 22 tests covering command construction, auth, dry-run, env, and errors Signed-off-by: Luke Inglis --- CLAUDE.md | 21 ++- factory/runners/__init__.py | 8 +- factory/runners/qwen.py | 168 +++++++++++++++++ tests/test_qwen_runner.py | 367 ++++++++++++++++++++++++++++++++++++ 4 files changed, 560 insertions(+), 4 deletions(-) create mode 100644 factory/runners/qwen.py create mode 100644 tests/test_qwen_runner.py diff --git a/CLAUDE.md b/CLAUDE.md index 0e26199c..322f27eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,9 +122,9 @@ ANTHROPIC_API_KEY = "sk-ant-..." ## Runners -The factory supports multiple CLI backends via the runner abstraction (`factory/runners/`). By default, it uses Claude Code (`claude` CLI). Bob Shell (`bob` CLI) and OpenAI Codex (`codex` CLI) are also supported as switchable alternatives. +The factory supports multiple CLI backends via the runner abstraction (`factory/runners/`). By default, it uses Claude Code (`claude` CLI). Bob Shell (`bob` CLI), OpenAI Codex (`codex` CLI), and Qwen Code (`qwen` CLI) are also supported as switchable alternatives. -**Runner selection:** Set `FACTORY_RUNNER=codex` (or `bob`) to switch backends, or pass `--runner codex` to individual commands. Default is `claude`. +**Runner selection:** Set `FACTORY_RUNNER=codex` (or `bob`, `qwen`) to switch backends, or pass `--runner codex` to individual commands. Default is `claude`. **Bob Shell specifics:** - Requires `BOBSHELL_API_KEY` environment variable to be set @@ -157,6 +157,23 @@ CODEX_API_KEY = "..." ``` Then run: `factory ceo /path/to/project --profile codex` +**Qwen Code specifics:** +- Auth via `DASHSCOPE_API_KEY` or `QWEN_API_KEY` environment variable (or set via config.toml profile). Warns if missing. +- CLI flags mirror Claude Code: `--append-system-prompt`, `-p`, `--model` +- Headless mode uses `--yolo` for auto-approve and `--output-format text` +- Model selection via `--model` flag (e.g., `qwen3-coder`) +- Install: `npm install -g @qwen-code/qwen-code` + +**Qwen dry-run mode:** Set `FACTORY_QWEN_DRY_RUN=1` to test Qwen integration without spending tokens. + +**Qwen config profile example** (`~/.factory/config.toml`): +```toml +[credentials.qwen] +FACTORY_RUNNER = "qwen" +DASHSCOPE_API_KEY = "sk-..." +``` +Then run: `factory ceo /path/to/project --profile qwen` + **Important:** Target projects should add `.factory/` to their `.gitignore`. The factory writes experiment data, usage logs, and potentially sensitive auth files (`.factory/.bob_auth`) to this directory. These are project-local artifacts that should not be committed to version control. ## Running the factory diff --git a/factory/runners/__init__.py b/factory/runners/__init__.py index 5be2d1c7..602286c1 100644 --- a/factory/runners/__init__.py +++ b/factory/runners/__init__.py @@ -10,26 +10,30 @@ from factory.runners.claude import ClaudeRunner from factory.runners.codex import CodexRunner, is_codex_dry_run from factory.runners.protocol import Runner +from factory.runners.qwen import QwenRunner, is_qwen_dry_run __all__ = [ "Runner", "ClaudeRunner", "BobRunner", "CodexRunner", + "QwenRunner", "get_runner", "RunnerName", "is_dry_run", "is_codex_dry_run", + "is_qwen_dry_run", "should_stream", "stream_subprocess", ] -RunnerName = Literal["claude", "bob", "codex"] +RunnerName = Literal["claude", "bob", "codex", "qwen"] _RUNNERS: dict[str, type[Runner]] = { "claude": ClaudeRunner, # type: ignore[dict-item] "bob": BobRunner, # type: ignore[dict-item] "codex": CodexRunner, # type: ignore[dict-item] + "qwen": QwenRunner, # type: ignore[dict-item] } @@ -42,7 +46,7 @@ def get_runner(name: str | None = None, project_path: Path | None = None) -> Run 3. Default to "claude" Args: - name: Runner name ("claude", "bob", or "codex"). + name: Runner name ("claude", "bob", "codex", or "qwen"). project_path: Path to the project. Passed to BobRunner for cycle state lookup. Raises: diff --git a/factory/runners/qwen.py b/factory/runners/qwen.py new file mode 100644 index 00000000..be38c98e --- /dev/null +++ b/factory/runners/qwen.py @@ -0,0 +1,168 @@ +"""QwenRunner — Qwen Code CLI backend implementation.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import subprocess +from pathlib import Path + +from factory.runners._stream import should_stream, stream_subprocess + +logger = logging.getLogger(__name__) + +_auth_warned = False + + +def _warn_auth() -> None: + """Log a warning if neither DASHSCOPE_API_KEY nor QWEN_API_KEY is set (once per process).""" + global _auth_warned # noqa: PLW0603 + if _auth_warned: + return + _auth_warned = True + if os.environ.get("DASHSCOPE_API_KEY") or os.environ.get("QWEN_API_KEY"): + return + logger.warning( + "Neither DASHSCOPE_API_KEY nor QWEN_API_KEY is set. " + "Qwen Code may fail to authenticate. " + "Set one directly or add it to a config.toml credential profile: " + '[credentials.qwen] DASHSCOPE_API_KEY = "sk-..."' + ) + + +def _make_qwen_env() -> dict[str, str]: + """Build subprocess env: strip VIRTUAL_ENV.""" + return {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"} + + +def is_qwen_dry_run() -> bool: + """Return True if Qwen dry-run mode is enabled.""" + from factory.user_config import resolve + + val = resolve("qwen_dry_run", env_var="FACTORY_QWEN_DRY_RUN") or "" + return val.lower() in ("1", "true", "yes") + + +class QwenRunner: + """Runner implementation for Qwen Code CLI.""" + + name: str = "qwen" + + async def headless( + self, + prompt: str, + task: str, + cwd: Path, + *, + timeout: float = 600.0, + model: str | None = None, + dangerously_skip_permissions: bool = True, + role: str = "unknown", + session_name: str | None = None, + ) -> tuple[str, int]: + """Run a headless Qwen Code invocation. + + Returns (stdout, return_code). + """ + _ = session_name + if is_qwen_dry_run(): + return self._dry_run_response(role, cwd, task) + + _warn_auth() + + cmd = [ + "qwen", + "--append-system-prompt", prompt, + "-p", task, + "--yolo", + "--output-format", "text", + ] + if model: + cmd.extend(["--model", model]) + + logger.info("QwenRunner headless: cwd=%s, model=%s, role=%s", cwd, model, role) + + env = _make_qwen_env() + + stream = should_stream() + prefix = f"[qwen:{role}]" if stream else None + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + env=env, + ) + stdout_bytes, stderr_bytes = await asyncio.wait_for( + stream_subprocess(proc, stream=stream, prefix=prefix), + timeout=timeout, + ) + except asyncio.TimeoutError: + proc.kill() # type: ignore[union-attr] + await proc.wait() # type: ignore[union-attr] + logger.error("QwenRunner timed out after %ss", timeout) + return f"Agent timed out after {timeout}s", 1 + except FileNotFoundError: + logger.error("'qwen' CLI not found on PATH") + return "Error: 'qwen' CLI not found on PATH", 1 + + stdout = stdout_bytes.decode() + stderr = stderr_bytes.decode() + + if proc.returncode != 0: + logger.warning("QwenRunner exited with code %d: %s", proc.returncode, stderr[:200]) + + return stdout, proc.returncode or 0 + + def interactive_run( + self, + prompt: str, + task: str, + cwd: Path, + *, + model: str | None = None, + role: str = "ceo", + dangerously_skip_permissions: bool = False, + session_name: str | None = None, + ) -> int: + """Run an interactive Qwen Code session as a subprocess. + + Returns the exit code so the caller can clean up in a finally block. + """ + _ = role, session_name + + if is_qwen_dry_run(): + print("[DRY-RUN] Would exec: qwen (interactive)") + print(f"[DRY-RUN] Task: {task[:200]}...") + return 0 + + _warn_auth() + + cmd = ["qwen", "--append-system-prompt", prompt] + if dangerously_skip_permissions: + cmd.append("--yolo") + cmd.append(task) + if model: + cmd.extend(["--model", model]) + + logger.info("QwenRunner interactive_run: cwd=%s", cwd) + + env = _make_qwen_env() + result = subprocess.run(cmd, cwd=cwd, env=env) + return result.returncode + + def _dry_run_response(self, role: str, cwd: Path, task: str) -> tuple[str, int]: + """Return a stub response for dry-run mode.""" + response = ( + f"[DRY-RUN] QwenRunner would have executed:\n" + f" role: {role}\n" + f" cwd: {cwd}\n" + f" task: {task[:100]}...\n" + f"\n" + f"Dry-run stub response: Task acknowledged." + ) + logger.info("QwenRunner dry-run: role=%s, cwd=%s", role, cwd) + return response, 0 diff --git a/tests/test_qwen_runner.py b/tests/test_qwen_runner.py new file mode 100644 index 00000000..daf686c5 --- /dev/null +++ b/tests/test_qwen_runner.py @@ -0,0 +1,367 @@ +"""Tests for factory/runners/qwen.py — QwenRunner implementation.""" + +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +import factory.runners.qwen as qwen_module +from factory.runners import get_runner +from factory.runners.qwen import QwenRunner, _warn_auth, is_qwen_dry_run + + +@pytest.fixture(autouse=True) +def _reset_qwen_auth() -> None: + qwen_module._auth_warned = False + + +class TestGetRunnerQwen: + def test_explicit_qwen(self) -> None: + runner = get_runner("qwen") + assert runner.name == "qwen" + + def test_from_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FACTORY_RUNNER", "qwen") + runner = get_runner() + assert runner.name == "qwen" + + def test_explicit_overrides_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FACTORY_RUNNER", "qwen") + runner = get_runner("claude") + assert runner.name == "claude" + + +class TestQwenDryRun: + def test_dry_run_true(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FACTORY_QWEN_DRY_RUN", "1") + assert is_qwen_dry_run() is True + + def test_dry_run_false(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) + assert is_qwen_dry_run() is False + + def test_dry_run_true_word(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FACTORY_QWEN_DRY_RUN", "true") + assert is_qwen_dry_run() is True + + async def test_headless_dry_run_returns_stub( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("FACTORY_QWEN_DRY_RUN", "1") + + runner = QwenRunner() + stdout, code = await runner.headless( + prompt="You are a test agent.", + task="Say hello", + cwd=tmp_path, + role="researcher", + ) + + assert code == 0 + assert "[DRY-RUN]" in stdout + assert "researcher" in stdout + + def test_interactive_run_dry_run( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + monkeypatch.setenv("FACTORY_QWEN_DRY_RUN", "1") + + runner = QwenRunner() + code = runner.interactive_run( + prompt="Test prompt", + task="Test task", + cwd=tmp_path, + role="ceo", + ) + + assert code == 0 + captured = capsys.readouterr() + assert "[DRY-RUN]" in captured.out + + +class TestQwenAuth: + def test_warns_without_key(self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None: + monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) + monkeypatch.delenv("QWEN_API_KEY", raising=False) + + with caplog.at_level("WARNING"): + _warn_auth() + + assert "DASHSCOPE_API_KEY" in caplog.text + + def test_no_warning_with_dashscope_key( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") + monkeypatch.delenv("QWEN_API_KEY", raising=False) + + with caplog.at_level("WARNING"): + _warn_auth() + + assert "DASHSCOPE_API_KEY" not in caplog.text + assert qwen_module._auth_warned is True + + def test_no_warning_with_qwen_key( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) + monkeypatch.setenv("QWEN_API_KEY", "test-key") + + with caplog.at_level("WARNING"): + _warn_auth() + + assert "DASHSCOPE_API_KEY" not in caplog.text + assert qwen_module._auth_warned is True + + def test_warns_only_once( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) + monkeypatch.delenv("QWEN_API_KEY", raising=False) + + with caplog.at_level("WARNING"): + _warn_auth() + caplog.clear() + _warn_auth() + + assert "DASHSCOPE_API_KEY" not in caplog.text + + +class TestQwenEnv: + def test_virtual_env_stripped(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VIRTUAL_ENV", "/some/venv") + + from factory.runners.qwen import _make_qwen_env + + env = _make_qwen_env() + assert "VIRTUAL_ENV" not in env + + def test_preserves_other_vars(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") + + from factory.runners.qwen import _make_qwen_env + + env = _make_qwen_env() + assert env["DASHSCOPE_API_KEY"] == "test-key" + + +class TestQwenHeadless: + async def test_builds_correct_command( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") + monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) + + runner = QwenRunner() + + with patch( + "factory.runners.qwen.stream_subprocess", new_callable=AsyncMock + ) as mock_stream: + mock_stream.return_value = (b"output", b"") + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_proc = AsyncMock() + mock_proc.returncode = 0 + mock_exec.return_value = mock_proc + + stdout, code = await runner.headless( + prompt="You are a test agent.", + task="Say hello", + cwd=tmp_path, + timeout=60.0, + model="qwen3-coder", + ) + + assert code == 0 + assert stdout == "output" + + call_args = mock_exec.call_args[0] + assert call_args[0] == "qwen" + assert "--append-system-prompt" in call_args + assert "-p" in call_args + assert "--yolo" in call_args + assert "--output-format" in call_args + assert "text" in call_args + assert "--model" in call_args + assert "qwen3-coder" in call_args + + async def test_no_model_flag_when_none( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") + monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) + + runner = QwenRunner() + + with patch( + "factory.runners.qwen.stream_subprocess", new_callable=AsyncMock + ) as mock_stream: + mock_stream.return_value = (b"ok", b"") + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_proc = AsyncMock() + mock_proc.returncode = 0 + mock_exec.return_value = mock_proc + + await runner.headless( + prompt="Test", + task="Test", + cwd=tmp_path, + model=None, + ) + + call_args = mock_exec.call_args[0] + assert "--model" not in call_args + + async def test_prompt_and_task_are_separate_args( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") + monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) + + runner = QwenRunner() + + with patch( + "factory.runners.qwen.stream_subprocess", new_callable=AsyncMock + ) as mock_stream: + mock_stream.return_value = (b"ok", b"") + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_proc = AsyncMock() + mock_proc.returncode = 0 + mock_exec.return_value = mock_proc + + await runner.headless( + prompt="You are the CEO.", + task="Run the experiment", + cwd=tmp_path, + ) + + call_args = mock_exec.call_args[0] + assert "You are the CEO." in call_args + assert "Run the experiment" in call_args + + async def test_handles_missing_binary( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") + monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) + + with patch( + "asyncio.create_subprocess_exec", + new_callable=AsyncMock, + side_effect=FileNotFoundError, + ): + runner = QwenRunner() + stdout, code = await runner.headless( + prompt="Test", + task="Test", + cwd=tmp_path, + ) + + assert code == 1 + assert "not found" in stdout.lower() + + async def test_passes_env_without_virtual_env( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") + monkeypatch.setenv("VIRTUAL_ENV", "/some/venv") + monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) + + runner = QwenRunner() + + with patch( + "factory.runners.qwen.stream_subprocess", new_callable=AsyncMock + ) as mock_stream: + mock_stream.return_value = (b"ok", b"") + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_proc = AsyncMock() + mock_proc.returncode = 0 + mock_exec.return_value = mock_proc + + await runner.headless( + prompt="Test", + task="Test", + cwd=tmp_path, + ) + + call_kwargs = mock_exec.call_args.kwargs + assert "VIRTUAL_ENV" not in call_kwargs["env"] + + +class TestQwenInteractive: + def test_interactive_run_builds_correct_command( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") + monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) + + runner = QwenRunner() + + with patch("subprocess.run") as mock_run: + mock_run.return_value = type("Result", (), {"returncode": 0})() + code = runner.interactive_run( + prompt="You are the CEO.", + task="Start session", + cwd=tmp_path, + model="qwen3-coder", + dangerously_skip_permissions=True, + ) + + assert code == 0 + cmd = mock_run.call_args[0][0] + assert cmd[0] == "qwen" + assert "--append-system-prompt" in cmd + assert "--yolo" in cmd + assert "--model" in cmd + assert "qwen3-coder" in cmd + + def test_interactive_run_no_yolo_without_skip( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") + monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) + + runner = QwenRunner() + + with patch("subprocess.run") as mock_run: + mock_run.return_value = type("Result", (), {"returncode": 0})() + runner.interactive_run( + prompt="Test", + task="Test", + cwd=tmp_path, + dangerously_skip_permissions=False, + ) + + cmd = mock_run.call_args[0][0] + assert "--yolo" not in cmd + + def test_interactive_run_passes_env( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") + monkeypatch.setenv("VIRTUAL_ENV", "/some/venv") + monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) + + runner = QwenRunner() + + with patch("subprocess.run") as mock_run: + mock_run.return_value = type("Result", (), {"returncode": 0})() + runner.interactive_run( + prompt="Test", + task="Test", + cwd=tmp_path, + ) + + call_kwargs = mock_run.call_args.kwargs + assert "VIRTUAL_ENV" not in call_kwargs["env"] From 54e7300fb2de01c536c475305be97922fff0013d Mon Sep 17 00:00:00 2001 From: Luke Inglis Date: Wed, 27 May 2026 12:02:19 -0400 Subject: [PATCH 2/3] fix: reorder auth warning flag to match codex pattern Signed-off-by: Luke Inglis --- factory/runners/qwen.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/factory/runners/qwen.py b/factory/runners/qwen.py index be38c98e..bec637ee 100644 --- a/factory/runners/qwen.py +++ b/factory/runners/qwen.py @@ -20,9 +20,10 @@ def _warn_auth() -> None: global _auth_warned # noqa: PLW0603 if _auth_warned: return - _auth_warned = True if os.environ.get("DASHSCOPE_API_KEY") or os.environ.get("QWEN_API_KEY"): + _auth_warned = True return + _auth_warned = True logger.warning( "Neither DASHSCOPE_API_KEY nor QWEN_API_KEY is set. " "Qwen Code may fail to authenticate. " From 641dc586c85e17482c0b58286b24133002a0f0b4 Mon Sep 17 00:00:00 2001 From: Luke Inglis Date: Wed, 27 May 2026 23:14:54 -0400 Subject: [PATCH 3/3] feat: replace Qwen Code runner with OpenCode runner - Add factory/runners/opencode.py implementing the Runner protocol - Uses 'opencode run' subcommand pattern with prompt prepended to task - --dangerously-skip-permissions gated on parameter (not unconditional) - Dry-run mode via FACTORY_OPENCODE_DRY_RUN=1 - Register 'opencode' in RunnerName and _RUNNERS registry - Update CLAUDE.md with OpenCode runner docs and config profile - Tests in tests/test_opencode_runner.py (20 tests) - Remove factory/runners/qwen.py and tests/test_qwen_runner.py Signed-off-by: Luke Inglis Signed-off-by: Luke Inglis --- CLAUDE.md | 27 +- factory/runners/__init__.py | 12 +- factory/runners/{qwen.py => opencode.py} | 93 +++---- ...qwen_runner.py => test_opencode_runner.py} | 248 +++++++++--------- 4 files changed, 178 insertions(+), 202 deletions(-) rename factory/runners/{qwen.py => opencode.py} (56%) rename tests/{test_qwen_runner.py => test_opencode_runner.py} (56%) diff --git a/CLAUDE.md b/CLAUDE.md index 322f27eb..fb923e4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,9 +122,9 @@ ANTHROPIC_API_KEY = "sk-ant-..." ## Runners -The factory supports multiple CLI backends via the runner abstraction (`factory/runners/`). By default, it uses Claude Code (`claude` CLI). Bob Shell (`bob` CLI), OpenAI Codex (`codex` CLI), and Qwen Code (`qwen` CLI) are also supported as switchable alternatives. +The factory supports multiple CLI backends via the runner abstraction (`factory/runners/`). By default, it uses Claude Code (`claude` CLI). Bob Shell (`bob` CLI), OpenAI Codex (`codex` CLI), and OpenCode (`opencode` CLI) are also supported as switchable alternatives. -**Runner selection:** Set `FACTORY_RUNNER=codex` (or `bob`, `qwen`) to switch backends, or pass `--runner codex` to individual commands. Default is `claude`. +**Runner selection:** Set `FACTORY_RUNNER=codex` (or `bob`, `opencode`) to switch backends, or pass `--runner codex` to individual commands. Default is `claude`. **Bob Shell specifics:** - Requires `BOBSHELL_API_KEY` environment variable to be set @@ -157,22 +157,21 @@ CODEX_API_KEY = "..." ``` Then run: `factory ceo /path/to/project --profile codex` -**Qwen Code specifics:** -- Auth via `DASHSCOPE_API_KEY` or `QWEN_API_KEY` environment variable (or set via config.toml profile). Warns if missing. -- CLI flags mirror Claude Code: `--append-system-prompt`, `-p`, `--model` -- Headless mode uses `--yolo` for auto-approve and `--output-format text` -- Model selection via `--model` flag (e.g., `qwen3-coder`) -- Install: `npm install -g @qwen-code/qwen-code` +**OpenCode specifics:** +- Auth handled by OpenCode's own provider system (`opencode auth login` or provider-specific env vars) +- Uses `opencode run ` subcommand pattern; system prompt is prepended to the task message (no `--append-system-prompt`) +- Headless mode uses `--dangerously-skip-permissions` (gated on parameter) and `--format default` +- Model selection via `--model` flag (e.g., `anthropic/claude-sonnet-4-20250514`) +- Install: `curl -fsSL https://opencode.ai/install | bash` -**Qwen dry-run mode:** Set `FACTORY_QWEN_DRY_RUN=1` to test Qwen integration without spending tokens. +**OpenCode dry-run mode:** Set `FACTORY_OPENCODE_DRY_RUN=1` to test OpenCode integration without spending tokens. -**Qwen config profile example** (`~/.factory/config.toml`): +**OpenCode config profile example** (`~/.factory/config.toml`): ```toml -[credentials.qwen] -FACTORY_RUNNER = "qwen" -DASHSCOPE_API_KEY = "sk-..." +[credentials.opencode] +FACTORY_RUNNER = "opencode" ``` -Then run: `factory ceo /path/to/project --profile qwen` +Then run: `factory ceo /path/to/project --profile opencode` **Important:** Target projects should add `.factory/` to their `.gitignore`. The factory writes experiment data, usage logs, and potentially sensitive auth files (`.factory/.bob_auth`) to this directory. These are project-local artifacts that should not be committed to version control. diff --git a/factory/runners/__init__.py b/factory/runners/__init__.py index 602286c1..fa769f48 100644 --- a/factory/runners/__init__.py +++ b/factory/runners/__init__.py @@ -10,30 +10,30 @@ from factory.runners.claude import ClaudeRunner from factory.runners.codex import CodexRunner, is_codex_dry_run from factory.runners.protocol import Runner -from factory.runners.qwen import QwenRunner, is_qwen_dry_run +from factory.runners.opencode import OpenCodeRunner, is_opencode_dry_run __all__ = [ "Runner", "ClaudeRunner", "BobRunner", "CodexRunner", - "QwenRunner", + "OpenCodeRunner", "get_runner", "RunnerName", "is_dry_run", "is_codex_dry_run", - "is_qwen_dry_run", + "is_opencode_dry_run", "should_stream", "stream_subprocess", ] -RunnerName = Literal["claude", "bob", "codex", "qwen"] +RunnerName = Literal["claude", "bob", "codex", "opencode"] _RUNNERS: dict[str, type[Runner]] = { "claude": ClaudeRunner, # type: ignore[dict-item] "bob": BobRunner, # type: ignore[dict-item] "codex": CodexRunner, # type: ignore[dict-item] - "qwen": QwenRunner, # type: ignore[dict-item] + "opencode": OpenCodeRunner, # type: ignore[dict-item] } @@ -46,7 +46,7 @@ def get_runner(name: str | None = None, project_path: Path | None = None) -> Run 3. Default to "claude" Args: - name: Runner name ("claude", "bob", "codex", or "qwen"). + name: Runner name ("claude", "bob", "codex", or "opencode"). project_path: Path to the project. Passed to BobRunner for cycle state lookup. Raises: diff --git a/factory/runners/qwen.py b/factory/runners/opencode.py similarity index 56% rename from factory/runners/qwen.py rename to factory/runners/opencode.py index bec637ee..12b3e9fb 100644 --- a/factory/runners/qwen.py +++ b/factory/runners/opencode.py @@ -1,4 +1,4 @@ -"""QwenRunner — Qwen Code CLI backend implementation.""" +"""OpenCodeRunner — OpenCode CLI backend implementation.""" from __future__ import annotations @@ -12,43 +12,29 @@ logger = logging.getLogger(__name__) -_auth_warned = False - -def _warn_auth() -> None: - """Log a warning if neither DASHSCOPE_API_KEY nor QWEN_API_KEY is set (once per process).""" - global _auth_warned # noqa: PLW0603 - if _auth_warned: - return - if os.environ.get("DASHSCOPE_API_KEY") or os.environ.get("QWEN_API_KEY"): - _auth_warned = True - return - _auth_warned = True - logger.warning( - "Neither DASHSCOPE_API_KEY nor QWEN_API_KEY is set. " - "Qwen Code may fail to authenticate. " - "Set one directly or add it to a config.toml credential profile: " - '[credentials.qwen] DASHSCOPE_API_KEY = "sk-..."' - ) - - -def _make_qwen_env() -> dict[str, str]: +def _make_opencode_env() -> dict[str, str]: """Build subprocess env: strip VIRTUAL_ENV.""" return {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"} -def is_qwen_dry_run() -> bool: - """Return True if Qwen dry-run mode is enabled.""" +def is_opencode_dry_run() -> bool: + """Return True if OpenCode dry-run mode is enabled.""" from factory.user_config import resolve - val = resolve("qwen_dry_run", env_var="FACTORY_QWEN_DRY_RUN") or "" + val = resolve("opencode_dry_run", env_var="FACTORY_OPENCODE_DRY_RUN") or "" return val.lower() in ("1", "true", "yes") -class QwenRunner: - """Runner implementation for Qwen Code CLI.""" +def _combine_prompt_and_task(prompt: str, task: str) -> str: + """Prepend system prompt to task since OpenCode has no --append-system-prompt flag.""" + return f"{prompt}\n\n---\n\n{task}" - name: str = "qwen" + +class OpenCodeRunner: + """Runner implementation for OpenCode CLI.""" + + name: str = "opencode" async def headless( self, @@ -62,32 +48,34 @@ async def headless( role: str = "unknown", session_name: str | None = None, ) -> tuple[str, int]: - """Run a headless Qwen Code invocation. + """Run a headless OpenCode invocation. Returns (stdout, return_code). """ _ = session_name - if is_qwen_dry_run(): + if is_opencode_dry_run(): return self._dry_run_response(role, cwd, task) - _warn_auth() + combined = _combine_prompt_and_task(prompt, task) cmd = [ - "qwen", - "--append-system-prompt", prompt, - "-p", task, - "--yolo", - "--output-format", "text", + "opencode", + "run", + combined, + "--dir", str(cwd), + "--format", "default", ] + if dangerously_skip_permissions: + cmd.append("--dangerously-skip-permissions") if model: cmd.extend(["--model", model]) - logger.info("QwenRunner headless: cwd=%s, model=%s, role=%s", cwd, model, role) + logger.info("OpenCodeRunner headless: cwd=%s, model=%s, role=%s", cwd, model, role) - env = _make_qwen_env() + env = _make_opencode_env() stream = should_stream() - prefix = f"[qwen:{role}]" if stream else None + prefix = f"[opencode:{role}]" if stream else None try: proc = await asyncio.create_subprocess_exec( @@ -104,17 +92,17 @@ async def headless( except asyncio.TimeoutError: proc.kill() # type: ignore[union-attr] await proc.wait() # type: ignore[union-attr] - logger.error("QwenRunner timed out after %ss", timeout) + logger.error("OpenCodeRunner timed out after %ss", timeout) return f"Agent timed out after {timeout}s", 1 except FileNotFoundError: - logger.error("'qwen' CLI not found on PATH") - return "Error: 'qwen' CLI not found on PATH", 1 + logger.error("'opencode' CLI not found on PATH") + return "Error: 'opencode' CLI not found on PATH", 1 stdout = stdout_bytes.decode() stderr = stderr_bytes.decode() if proc.returncode != 0: - logger.warning("QwenRunner exited with code %d: %s", proc.returncode, stderr[:200]) + logger.warning("OpenCodeRunner exited with code %d: %s", proc.returncode, stderr[:200]) return stdout, proc.returncode or 0 @@ -129,41 +117,40 @@ def interactive_run( dangerously_skip_permissions: bool = False, session_name: str | None = None, ) -> int: - """Run an interactive Qwen Code session as a subprocess. + """Run an interactive OpenCode session as a subprocess. Returns the exit code so the caller can clean up in a finally block. """ _ = role, session_name - if is_qwen_dry_run(): - print("[DRY-RUN] Would exec: qwen (interactive)") + if is_opencode_dry_run(): + print("[DRY-RUN] Would exec: opencode run --interactive") print(f"[DRY-RUN] Task: {task[:200]}...") return 0 - _warn_auth() + combined = _combine_prompt_and_task(prompt, task) - cmd = ["qwen", "--append-system-prompt", prompt] + cmd = ["opencode", "run", "--interactive", combined, "--dir", str(cwd)] if dangerously_skip_permissions: - cmd.append("--yolo") - cmd.append(task) + cmd.append("--dangerously-skip-permissions") if model: cmd.extend(["--model", model]) - logger.info("QwenRunner interactive_run: cwd=%s", cwd) + logger.info("OpenCodeRunner interactive_run: cwd=%s", cwd) - env = _make_qwen_env() + env = _make_opencode_env() result = subprocess.run(cmd, cwd=cwd, env=env) return result.returncode def _dry_run_response(self, role: str, cwd: Path, task: str) -> tuple[str, int]: """Return a stub response for dry-run mode.""" response = ( - f"[DRY-RUN] QwenRunner would have executed:\n" + f"[DRY-RUN] OpenCodeRunner would have executed:\n" f" role: {role}\n" f" cwd: {cwd}\n" f" task: {task[:100]}...\n" f"\n" f"Dry-run stub response: Task acknowledged." ) - logger.info("QwenRunner dry-run: role=%s, cwd=%s", role, cwd) + logger.info("OpenCodeRunner dry-run: role=%s, cwd=%s", role, cwd) return response, 0 diff --git a/tests/test_qwen_runner.py b/tests/test_opencode_runner.py similarity index 56% rename from tests/test_qwen_runner.py rename to tests/test_opencode_runner.py index daf686c5..2797554d 100644 --- a/tests/test_qwen_runner.py +++ b/tests/test_opencode_runner.py @@ -1,55 +1,49 @@ -"""Tests for factory/runners/qwen.py — QwenRunner implementation.""" +"""Tests for factory/runners/opencode.py — OpenCodeRunner implementation.""" from pathlib import Path from unittest.mock import AsyncMock, patch import pytest -import factory.runners.qwen as qwen_module from factory.runners import get_runner -from factory.runners.qwen import QwenRunner, _warn_auth, is_qwen_dry_run +from factory.runners.opencode import OpenCodeRunner, is_opencode_dry_run -@pytest.fixture(autouse=True) -def _reset_qwen_auth() -> None: - qwen_module._auth_warned = False - - -class TestGetRunnerQwen: - def test_explicit_qwen(self) -> None: - runner = get_runner("qwen") - assert runner.name == "qwen" +class TestGetRunnerOpenCode: + def test_explicit_opencode(self) -> None: + runner = get_runner("opencode") + assert runner.name == "opencode" def test_from_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("FACTORY_RUNNER", "qwen") + monkeypatch.setenv("FACTORY_RUNNER", "opencode") runner = get_runner() - assert runner.name == "qwen" + assert runner.name == "opencode" def test_explicit_overrides_env(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("FACTORY_RUNNER", "qwen") + monkeypatch.setenv("FACTORY_RUNNER", "opencode") runner = get_runner("claude") assert runner.name == "claude" -class TestQwenDryRun: +class TestOpenCodeDryRun: def test_dry_run_true(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("FACTORY_QWEN_DRY_RUN", "1") - assert is_qwen_dry_run() is True + monkeypatch.setenv("FACTORY_OPENCODE_DRY_RUN", "1") + assert is_opencode_dry_run() is True def test_dry_run_false(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) - assert is_qwen_dry_run() is False + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) + assert is_opencode_dry_run() is False def test_dry_run_true_word(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("FACTORY_QWEN_DRY_RUN", "true") - assert is_qwen_dry_run() is True + monkeypatch.setenv("FACTORY_OPENCODE_DRY_RUN", "true") + assert is_opencode_dry_run() is True async def test_headless_dry_run_returns_stub( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setenv("FACTORY_QWEN_DRY_RUN", "1") + monkeypatch.setenv("FACTORY_OPENCODE_DRY_RUN", "1") - runner = QwenRunner() + runner = OpenCodeRunner() stdout, code = await runner.headless( prompt="You are a test agent.", task="Say hello", @@ -64,9 +58,9 @@ async def test_headless_dry_run_returns_stub( def test_interactive_run_dry_run( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - monkeypatch.setenv("FACTORY_QWEN_DRY_RUN", "1") + monkeypatch.setenv("FACTORY_OPENCODE_DRY_RUN", "1") - runner = QwenRunner() + runner = OpenCodeRunner() code = runner.interactive_run( prompt="Test prompt", task="Test task", @@ -79,83 +73,34 @@ def test_interactive_run_dry_run( assert "[DRY-RUN]" in captured.out -class TestQwenAuth: - def test_warns_without_key(self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None: - monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) - monkeypatch.delenv("QWEN_API_KEY", raising=False) - - with caplog.at_level("WARNING"): - _warn_auth() - - assert "DASHSCOPE_API_KEY" in caplog.text - - def test_no_warning_with_dashscope_key( - self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture - ) -> None: - monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") - monkeypatch.delenv("QWEN_API_KEY", raising=False) - - with caplog.at_level("WARNING"): - _warn_auth() - - assert "DASHSCOPE_API_KEY" not in caplog.text - assert qwen_module._auth_warned is True - - def test_no_warning_with_qwen_key( - self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture - ) -> None: - monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) - monkeypatch.setenv("QWEN_API_KEY", "test-key") - - with caplog.at_level("WARNING"): - _warn_auth() - - assert "DASHSCOPE_API_KEY" not in caplog.text - assert qwen_module._auth_warned is True - - def test_warns_only_once( - self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture - ) -> None: - monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) - monkeypatch.delenv("QWEN_API_KEY", raising=False) - - with caplog.at_level("WARNING"): - _warn_auth() - caplog.clear() - _warn_auth() - - assert "DASHSCOPE_API_KEY" not in caplog.text - - -class TestQwenEnv: +class TestOpenCodeEnv: def test_virtual_env_stripped(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("VIRTUAL_ENV", "/some/venv") - from factory.runners.qwen import _make_qwen_env + from factory.runners.opencode import _make_opencode_env - env = _make_qwen_env() + env = _make_opencode_env() assert "VIRTUAL_ENV" not in env def test_preserves_other_vars(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") + monkeypatch.setenv("SOME_VAR", "test-value") - from factory.runners.qwen import _make_qwen_env + from factory.runners.opencode import _make_opencode_env - env = _make_qwen_env() - assert env["DASHSCOPE_API_KEY"] == "test-key" + env = _make_opencode_env() + assert env["SOME_VAR"] == "test-value" -class TestQwenHeadless: +class TestOpenCodeHeadless: async def test_builds_correct_command( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") - monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) - runner = QwenRunner() + runner = OpenCodeRunner() with patch( - "factory.runners.qwen.stream_subprocess", new_callable=AsyncMock + "factory.runners.opencode.stream_subprocess", new_callable=AsyncMock ) as mock_stream: mock_stream.return_value = (b"output", b"") @@ -171,32 +116,31 @@ async def test_builds_correct_command( task="Say hello", cwd=tmp_path, timeout=60.0, - model="qwen3-coder", + model="anthropic/claude-sonnet-4-20250514", ) assert code == 0 assert stdout == "output" call_args = mock_exec.call_args[0] - assert call_args[0] == "qwen" - assert "--append-system-prompt" in call_args - assert "-p" in call_args - assert "--yolo" in call_args - assert "--output-format" in call_args - assert "text" in call_args + assert call_args[0] == "opencode" + assert call_args[1] == "run" + assert "--format" in call_args + assert "default" in call_args + assert "--dangerously-skip-permissions" in call_args + assert "--dir" in call_args assert "--model" in call_args - assert "qwen3-coder" in call_args + assert "anthropic/claude-sonnet-4-20250514" in call_args async def test_no_model_flag_when_none( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") - monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) - runner = QwenRunner() + runner = OpenCodeRunner() with patch( - "factory.runners.qwen.stream_subprocess", new_callable=AsyncMock + "factory.runners.opencode.stream_subprocess", new_callable=AsyncMock ) as mock_stream: mock_stream.return_value = (b"ok", b"") @@ -217,16 +161,15 @@ async def test_no_model_flag_when_none( call_args = mock_exec.call_args[0] assert "--model" not in call_args - async def test_prompt_and_task_are_separate_args( + async def test_prompt_prepended_to_task( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") - monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) - runner = QwenRunner() + runner = OpenCodeRunner() with patch( - "factory.runners.qwen.stream_subprocess", new_callable=AsyncMock + "factory.runners.opencode.stream_subprocess", new_callable=AsyncMock ) as mock_stream: mock_stream.return_value = (b"ok", b"") @@ -244,21 +187,51 @@ async def test_prompt_and_task_are_separate_args( ) call_args = mock_exec.call_args[0] - assert "You are the CEO." in call_args - assert "Run the experiment" in call_args + combined = call_args[2] + assert "You are the CEO." in combined + assert "Run the experiment" in combined + assert "---" in combined + + async def test_no_skip_permissions_when_false( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) + + runner = OpenCodeRunner() + + with patch( + "factory.runners.opencode.stream_subprocess", new_callable=AsyncMock + ) as mock_stream: + mock_stream.return_value = (b"ok", b"") + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_proc = AsyncMock() + mock_proc.returncode = 0 + mock_exec.return_value = mock_proc + + await runner.headless( + prompt="Test", + task="Test", + cwd=tmp_path, + dangerously_skip_permissions=False, + ) + + call_args = mock_exec.call_args[0] + assert "--dangerously-skip-permissions" not in call_args async def test_handles_missing_binary( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") - monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) with patch( "asyncio.create_subprocess_exec", new_callable=AsyncMock, side_effect=FileNotFoundError, ): - runner = QwenRunner() + runner = OpenCodeRunner() stdout, code = await runner.headless( prompt="Test", task="Test", @@ -271,14 +244,13 @@ async def test_handles_missing_binary( async def test_passes_env_without_virtual_env( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") monkeypatch.setenv("VIRTUAL_ENV", "/some/venv") - monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) - runner = QwenRunner() + runner = OpenCodeRunner() with patch( - "factory.runners.qwen.stream_subprocess", new_callable=AsyncMock + "factory.runners.opencode.stream_subprocess", new_callable=AsyncMock ) as mock_stream: mock_stream.return_value = (b"ok", b"") @@ -299,14 +271,13 @@ async def test_passes_env_without_virtual_env( assert "VIRTUAL_ENV" not in call_kwargs["env"] -class TestQwenInteractive: +class TestOpenCodeInteractive: def test_interactive_run_builds_correct_command( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") - monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) - runner = QwenRunner() + runner = OpenCodeRunner() with patch("subprocess.run") as mock_run: mock_run.return_value = type("Result", (), {"returncode": 0})() @@ -314,25 +285,25 @@ def test_interactive_run_builds_correct_command( prompt="You are the CEO.", task="Start session", cwd=tmp_path, - model="qwen3-coder", + model="anthropic/claude-sonnet-4-20250514", dangerously_skip_permissions=True, ) assert code == 0 cmd = mock_run.call_args[0][0] - assert cmd[0] == "qwen" - assert "--append-system-prompt" in cmd - assert "--yolo" in cmd + assert cmd[0] == "opencode" + assert cmd[1] == "run" + assert "--interactive" in cmd + assert "--dangerously-skip-permissions" in cmd assert "--model" in cmd - assert "qwen3-coder" in cmd + assert "anthropic/claude-sonnet-4-20250514" in cmd - def test_interactive_run_no_yolo_without_skip( + def test_interactive_run_no_skip_permissions_without_flag( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") - monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) - runner = QwenRunner() + runner = OpenCodeRunner() with patch("subprocess.run") as mock_run: mock_run.return_value = type("Result", (), {"returncode": 0})() @@ -344,16 +315,15 @@ def test_interactive_run_no_yolo_without_skip( ) cmd = mock_run.call_args[0][0] - assert "--yolo" not in cmd + assert "--dangerously-skip-permissions" not in cmd def test_interactive_run_passes_env( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setenv("DASHSCOPE_API_KEY", "test-key") monkeypatch.setenv("VIRTUAL_ENV", "/some/venv") - monkeypatch.delenv("FACTORY_QWEN_DRY_RUN", raising=False) + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) - runner = QwenRunner() + runner = OpenCodeRunner() with patch("subprocess.run") as mock_run: mock_run.return_value = type("Result", (), {"returncode": 0})() @@ -365,3 +335,23 @@ def test_interactive_run_passes_env( call_kwargs = mock_run.call_args.kwargs assert "VIRTUAL_ENV" not in call_kwargs["env"] + + def test_interactive_prompt_prepended_to_task( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) + + runner = OpenCodeRunner() + + with patch("subprocess.run") as mock_run: + mock_run.return_value = type("Result", (), {"returncode": 0})() + runner.interactive_run( + prompt="You are the CEO.", + task="Start session", + cwd=tmp_path, + ) + + cmd = mock_run.call_args[0][0] + combined_args = [arg for arg in cmd if "You are the CEO." in arg] + assert len(combined_args) == 1 + assert "Start session" in combined_args[0]