From a3896a545dbdba4c92d21276298046d88640dd29 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 19 Mar 2026 14:43:13 -0700 Subject: [PATCH 1/3] feat(engines): add Kilocode engine adapter (#417) Implements KilocodeAdapter as an external subprocess engine that delegates code execution to the `kilo run --auto` CLI. Follows the same SubprocessAdapter pattern as claude-code and opencode adapters. - Add codeframe/core/adapters/kilocode.py with KilocodeAdapter - Passes prompt as positional CLI arg (not stdin) - Handles exit code 124 as timeout failure - Configurable via KILOCODE_PATH, KILOCODE_MODEL, KILOCODE_FLAGS env vars - Register "kilocode" in engine_registry.py (VALID_ENGINES + EXTERNAL_ENGINES) - Update CLI --engine help text to include "kilocode" - Document env vars in .env.example - Add 14 unit tests covering all adapter behaviors Closes #417 --- .env.example | 14 +++ codeframe/cli/app.py | 4 +- codeframe/core/adapters/kilocode.py | 110 +++++++++++++++++++ codeframe/core/engine_registry.py | 9 ++ tests/core/adapters/test_kilocode.py | 157 +++++++++++++++++++++++++++ tests/core/test_engine_registry.py | 9 ++ 6 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 codeframe/core/adapters/kilocode.py create mode 100644 tests/core/adapters/test_kilocode.py diff --git a/.env.example b/.env.example index 77646df0..6da37cfb 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,20 @@ ANTHROPIC_API_KEY=sk-ant-api03-... # Optional for Sprint 1 - only needed if using GPT-4 agents OPENAI_API_KEY=sk-... +# ============================================================================ +# External Engine Configuration +# ============================================================================ + +# Kilocode engine (cf work start --engine kilocode) +# Path to the kilo binary; defaults to 'kilo' on $PATH +# KILOCODE_PATH=kilo + +# Optional model override passed to kilo --model +# KILOCODE_MODEL= + +# Optional extra CLI flags passed to kilo (space-separated) +# KILOCODE_FLAGS= + # ============================================================================ # Database Configuration # ============================================================================ diff --git a/codeframe/cli/app.py b/codeframe/cli/app.py index ac115766..32220c97 100644 --- a/codeframe/cli/app.py +++ b/codeframe/cli/app.py @@ -2306,7 +2306,7 @@ def work_start( engine: Optional[str] = typer.Option( None, "--engine", - help="Agent engine: react (default), plan (legacy), claude-code, codex, opencode, or built-in", + help="Agent engine: react (default), plan (legacy), claude-code, codex, opencode, kilocode, or built-in", ), stall_timeout: int = typer.Option( 300, @@ -3537,7 +3537,7 @@ def batch_run( engine: Optional[str] = typer.Option( None, "--engine", - help="Agent engine: react (default), plan (legacy), claude-code, codex, opencode, or built-in", + help="Agent engine: react (default), plan (legacy), claude-code, codex, opencode, kilocode, or built-in", ), stall_timeout: int = typer.Option( 300, diff --git a/codeframe/core/adapters/kilocode.py b/codeframe/core/adapters/kilocode.py new file mode 100644 index 00000000..acdc010f --- /dev/null +++ b/codeframe/core/adapters/kilocode.py @@ -0,0 +1,110 @@ +"""Kilocode adapter for delegating task execution to the kilo CLI.""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +from codeframe.core.adapters.agent_adapter import AgentResult +from codeframe.core.adapters.subprocess_adapter import SubprocessAdapter + +# Exit code used by kilo when the timeout is exceeded +_KILO_TIMEOUT_EXIT_CODE = 124 + + +class KilocodeAdapter(SubprocessAdapter): + """Adapter that delegates code execution to Kilocode CLI. + + Invokes ``kilo run --auto --workspace `` for headless + non-interactive execution. The prompt is passed as a positional argument + (not via stdin), matching Kilocode's CLI interface. + + Exit codes: + 0 — success + 124 — timeout exceeded (mirrors the standard ``timeout(1)`` convention) + * — execution error + + Configuration via environment variables: + KILOCODE_PATH — path to kilo binary (default: "kilo", resolved from $PATH) + KILOCODE_MODEL — optional model override passed as ``--model`` + KILOCODE_FLAGS — optional extra CLI flags (space-separated string) + + Requires Kilocode to be installed: + https://kilocode.ai/ + """ + + def __init__( + self, + *, + timeout_s: int | None = None, + ) -> None: + binary = os.environ.get("KILOCODE_PATH") or "kilo" + super().__init__(binary=binary, timeout_s=timeout_s) + + @property + def name(self) -> str: # noqa: D102 + return "kilocode" + + @classmethod + def check_ready(cls) -> dict[str, bool]: + """Check if the kilo binary is available on PATH.""" + binary = os.environ.get("KILOCODE_PATH") or "kilo" + return {"kilo_binary": shutil.which(binary) is not None} + + def build_command(self, prompt: str, workspace_path: Path) -> list[str]: + """Build the kilo CLI command. + + Kilocode takes the prompt as a positional argument, with ``--auto`` + for non-interactive execution and ``--workspace`` for the repo root. + + Args: + prompt: The task prompt passed as a positional argument. + workspace_path: Workspace root passed as ``--workspace``. + + Returns: + Command list for subprocess.Popen. + """ + cmd = [ + self._binary_path, + "run", + prompt, + "--auto", + "--workspace", + str(workspace_path), + ] + + model = os.environ.get("KILOCODE_MODEL") + if model: + cmd.extend(["--model", model]) + + extra_flags_str = os.environ.get("KILOCODE_FLAGS", "").strip() + if extra_flags_str: + cmd.extend(extra_flags_str.split()) + + return cmd + + def get_stdin(self, prompt: str) -> str | None: + """Return None — prompt is passed as a positional CLI argument, not stdin.""" + return None + + def _map_result( + self, + exit_code: int, + stdout: str, + stderr: str, + workspace_path: Path, + ) -> AgentResult: + """Map kilo exit codes to AgentResult. + + Exit code 124 indicates a timeout (kilo's standard timeout sentinel), + which is surfaced as a failed result with a descriptive message. + All other non-zero codes use the base class logic for blocker detection. + """ + if exit_code == _KILO_TIMEOUT_EXIT_CODE: + return AgentResult( + status="failed", + output=stdout, + error="Kilocode execution timed out (exit code 124)", + ) + return super()._map_result(exit_code, stdout, stderr, workspace_path) diff --git a/codeframe/core/engine_registry.py b/codeframe/core/engine_registry.py index 4771c941..1acea6ff 100644 --- a/codeframe/core/engine_registry.py +++ b/codeframe/core/engine_registry.py @@ -15,6 +15,7 @@ "claude-code", "codex", "opencode", + "kilocode", "built-in", # Alias for "react" }) @@ -23,6 +24,7 @@ "claude-code", "codex", "opencode", + "kilocode", }) # Builtin engines that need workspace + LLM provider @@ -96,6 +98,10 @@ def get_external_adapter(engine: str, **kwargs: Any) -> AgentAdapter: from codeframe.core.adapters.opencode import OpenCodeAdapter return OpenCodeAdapter() + elif engine == "kilocode": + from codeframe.core.adapters.kilocode import KilocodeAdapter + + return KilocodeAdapter() else: raise ValueError( f"Unknown external engine '{engine}'. " @@ -201,6 +207,9 @@ def _get_adapter_class(engine: str) -> type | None: elif engine == "opencode": from codeframe.core.adapters.opencode import OpenCodeAdapter return OpenCodeAdapter + elif engine == "kilocode": + from codeframe.core.adapters.kilocode import KilocodeAdapter + return KilocodeAdapter return None diff --git a/tests/core/adapters/test_kilocode.py b/tests/core/adapters/test_kilocode.py new file mode 100644 index 00000000..973c8ee8 --- /dev/null +++ b/tests/core/adapters/test_kilocode.py @@ -0,0 +1,157 @@ +"""Tests for Kilocode adapter.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from codeframe.core.adapters.agent_adapter import AgentAdapter +from codeframe.core.adapters.kilocode import KilocodeAdapter + + +class TestKilocodeAdapter: + """Unit tests for KilocodeAdapter.""" + + @pytest.fixture(autouse=True) + def _no_git(self): + """Prevent _detect_modified_files from calling real git.""" + with patch.object(KilocodeAdapter, "_detect_modified_files", return_value=[]): + yield + + def test_name(self) -> None: + with patch("shutil.which", return_value="/usr/bin/kilo"): + adapter = KilocodeAdapter() + assert adapter.name == "kilocode" + + def test_conforms_to_protocol(self) -> None: + with patch("shutil.which", return_value="/usr/bin/kilo"): + adapter = KilocodeAdapter() + assert isinstance(adapter, AgentAdapter) + + def test_raises_if_kilo_not_installed(self) -> None: + with patch("shutil.which", return_value=None): + with pytest.raises(EnvironmentError, match="not found on PATH"): + KilocodeAdapter() + + def test_build_command_includes_prompt_and_auto_flag(self) -> None: + with patch("shutil.which", return_value="/usr/bin/kilo"): + adapter = KilocodeAdapter() + cmd = adapter.build_command("do the thing", Path("/tmp/repo")) + assert cmd[0] == "/usr/bin/kilo" + assert cmd[1] == "run" + assert "do the thing" in cmd + assert "--auto" in cmd + assert "--workspace" in cmd + assert "/tmp/repo" in cmd + + def test_prompt_is_not_sent_via_stdin(self) -> None: + with patch("shutil.which", return_value="/usr/bin/kilo"): + adapter = KilocodeAdapter() + assert adapter.get_stdin("my prompt") is None + + def test_build_command_includes_model_when_env_set(self, monkeypatch) -> None: + monkeypatch.setenv("KILOCODE_MODEL", "claude-3-5-sonnet") + with patch("shutil.which", return_value="/usr/bin/kilo"): + adapter = KilocodeAdapter() + cmd = adapter.build_command("prompt", Path("/tmp/repo")) + assert "--model" in cmd + idx = cmd.index("--model") + assert cmd[idx + 1] == "claude-3-5-sonnet" + + def test_build_command_extra_flags_from_env(self, monkeypatch) -> None: + monkeypatch.setenv("KILOCODE_FLAGS", "--verbose --log-level debug") + with patch("shutil.which", return_value="/usr/bin/kilo"): + adapter = KilocodeAdapter() + cmd = adapter.build_command("prompt", Path("/tmp/repo")) + assert "--verbose" in cmd + assert "--log-level" in cmd + assert "debug" in cmd + + def test_custom_binary_from_env(self, monkeypatch) -> None: + monkeypatch.setenv("KILOCODE_PATH", "/opt/kilo/bin/kilo") + with patch("shutil.which", return_value="/opt/kilo/bin/kilo"): + adapter = KilocodeAdapter() + assert adapter._binary_path == "/opt/kilo/bin/kilo" + + def test_check_ready_when_binary_present(self) -> None: + with patch("shutil.which", return_value="/usr/bin/kilo"): + result = KilocodeAdapter.check_ready() + assert result["kilo_binary"] is True + + def test_check_ready_when_binary_missing(self) -> None: + with patch("shutil.which", return_value=None): + result = KilocodeAdapter.check_ready() + assert result["kilo_binary"] is False + + def test_successful_execution(self) -> None: + with patch("shutil.which", return_value="/usr/bin/kilo"): + adapter = KilocodeAdapter() + + mock_process = MagicMock() + mock_process.stdout = iter(["Wrote src/foo.py\n"]) + mock_process.stderr = MagicMock() + mock_process.stderr.read.return_value = "" + mock_process.stdin = None + mock_process.returncode = 0 + mock_process.wait.return_value = None + + with patch("subprocess.Popen", return_value=mock_process): + result = adapter.run("task-1", "implement foo", Path("/tmp/repo")) + + assert result.status == "completed" + assert "Wrote src/foo.py" in result.output + + def test_failed_execution_nonzero_exit(self) -> None: + with patch("shutil.which", return_value="/usr/bin/kilo"): + adapter = KilocodeAdapter() + + mock_process = MagicMock() + mock_process.stdout = iter([]) + mock_process.stderr = MagicMock() + mock_process.stderr.read.return_value = "kilo: fatal error" + mock_process.stdin = None + mock_process.returncode = 1 + mock_process.wait.return_value = None + + with patch("subprocess.Popen", return_value=mock_process): + result = adapter.run("task-1", "implement foo", Path("/tmp/repo")) + + assert result.status == "failed" + + def test_timeout_exit_code_124_maps_to_failed(self) -> None: + with patch("shutil.which", return_value="/usr/bin/kilo"): + adapter = KilocodeAdapter() + + mock_process = MagicMock() + mock_process.stdout = iter([]) + mock_process.stderr = MagicMock() + mock_process.stderr.read.return_value = "" + mock_process.stdin = None + mock_process.returncode = 124 + mock_process.wait.return_value = None + + with patch("subprocess.Popen", return_value=mock_process): + result = adapter.run("task-1", "implement foo", Path("/tmp/repo")) + + assert result.status == "failed" + assert "timed out" in (result.error or "").lower() + + def test_event_callback_receives_output_lines(self) -> None: + with patch("shutil.which", return_value="/usr/bin/kilo"): + adapter = KilocodeAdapter() + + events: list = [] + mock_process = MagicMock() + mock_process.stdout = iter(["step 1\n", "step 2\n"]) + mock_process.stderr = MagicMock() + mock_process.stderr.read.return_value = "" + mock_process.stdin = None + mock_process.returncode = 0 + mock_process.wait.return_value = None + + with patch("subprocess.Popen", return_value=mock_process): + adapter.run("task-1", "do work", Path("/tmp/repo"), on_event=events.append) + + assert len(events) == 2 + assert events[0].data["line"] == "step 1" + assert events[1].data["line"] == "step 2" diff --git a/tests/core/test_engine_registry.py b/tests/core/test_engine_registry.py index a596750a..a8d6ee35 100644 --- a/tests/core/test_engine_registry.py +++ b/tests/core/test_engine_registry.py @@ -55,6 +55,9 @@ def test_claude_code_is_external(self): def test_opencode_is_external(self): assert is_external_engine("opencode") is True + def test_kilocode_is_external(self): + assert is_external_engine("kilocode") is True + def test_react_is_not_external(self): assert is_external_engine("react") is False @@ -75,6 +78,12 @@ def test_opencode_adapter(self): assert adapter.name == "opencode" assert isinstance(adapter, AgentAdapter) + def test_kilocode_adapter(self): + with patch("shutil.which", return_value="/usr/bin/kilo"): + adapter = get_external_adapter("kilocode") + assert adapter.name == "kilocode" + assert isinstance(adapter, AgentAdapter) + def test_invalid_engine_raises(self): with pytest.raises(ValueError, match="Unknown external engine"): get_external_adapter("react") From d3502c729b4c251d15a719559c195077e4757f64 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 19 Mar 2026 15:47:48 -0700 Subject: [PATCH 2/3] fix(engines): address review feedback on KilocodeAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use shlex.split() for KILOCODE_FLAGS instead of str.split() — handles quoted values correctly and avoids argument smuggling - Extract _resolve_binary() staticmethod to eliminate duplicated KILOCODE_PATH resolution between __init__ and check_ready - Add requirements() classmethod for consistency with CodexAdapter and compatibility with `cf engines check kilocode` - Forward **kwargs in get_external_adapter() so timeout_s and other params reach the adapter (was silently dropped) - Fix shutil.which patch path in tests to target subprocess_adapter module - Add pytestmark = pytest.mark.v2 to both test files for explicit marking - Add docstring note on prompt-as-positional-arg platform limits (macOS 256KB) --- codeframe/core/adapters/kilocode.py | 28 ++++++++++--- codeframe/core/engine_registry.py | 2 +- tests/core/adapters/test_kilocode.py | 59 +++++++++++++++++++++------- tests/core/test_engine_registry.py | 2 + 4 files changed, 69 insertions(+), 22 deletions(-) diff --git a/codeframe/core/adapters/kilocode.py b/codeframe/core/adapters/kilocode.py index acdc010f..651f224f 100644 --- a/codeframe/core/adapters/kilocode.py +++ b/codeframe/core/adapters/kilocode.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import shlex import shutil from pathlib import Path @@ -20,6 +21,11 @@ class KilocodeAdapter(SubprocessAdapter): non-interactive execution. The prompt is passed as a positional argument (not via stdin), matching Kilocode's CLI interface. + Note on prompt length: the prompt is passed as a single positional argument. + Linux supports up to ~2 MB per argument, but macOS caps individual arguments + at 256 KB. Very large task contexts assembled by TaskContextPackager may fail + on macOS. If Kilocode adds stdin support in a future release, prefer that path. + Exit codes: 0 — success 124 — timeout exceeded (mirrors the standard ``timeout(1)`` convention) @@ -28,7 +34,7 @@ class KilocodeAdapter(SubprocessAdapter): Configuration via environment variables: KILOCODE_PATH — path to kilo binary (default: "kilo", resolved from $PATH) KILOCODE_MODEL — optional model override passed as ``--model`` - KILOCODE_FLAGS — optional extra CLI flags (space-separated string) + KILOCODE_FLAGS — optional extra CLI flags (shell-quoted, e.g. ``--flag "val"``) Requires Kilocode to be installed: https://kilocode.ai/ @@ -39,18 +45,28 @@ def __init__( *, timeout_s: int | None = None, ) -> None: - binary = os.environ.get("KILOCODE_PATH") or "kilo" - super().__init__(binary=binary, timeout_s=timeout_s) + super().__init__(binary=self._resolve_binary(), timeout_s=timeout_s) @property def name(self) -> str: # noqa: D102 return "kilocode" + @staticmethod + def _resolve_binary() -> str: + """Return the kilo binary path from env or default.""" + return os.environ.get("KILOCODE_PATH") or "kilo" + + @classmethod + def requirements(cls) -> dict[str, str]: + """Return environment variables recognised by ``cf engines check``.""" + return { + "KILOCODE_PATH": "Path to kilo binary (optional — defaults to 'kilo' on $PATH)", + } + @classmethod def check_ready(cls) -> dict[str, bool]: """Check if the kilo binary is available on PATH.""" - binary = os.environ.get("KILOCODE_PATH") or "kilo" - return {"kilo_binary": shutil.which(binary) is not None} + return {"kilo_binary": shutil.which(cls._resolve_binary()) is not None} def build_command(self, prompt: str, workspace_path: Path) -> list[str]: """Build the kilo CLI command. @@ -80,7 +96,7 @@ def build_command(self, prompt: str, workspace_path: Path) -> list[str]: extra_flags_str = os.environ.get("KILOCODE_FLAGS", "").strip() if extra_flags_str: - cmd.extend(extra_flags_str.split()) + cmd.extend(shlex.split(extra_flags_str)) return cmd diff --git a/codeframe/core/engine_registry.py b/codeframe/core/engine_registry.py index 1acea6ff..b7cf52fd 100644 --- a/codeframe/core/engine_registry.py +++ b/codeframe/core/engine_registry.py @@ -101,7 +101,7 @@ def get_external_adapter(engine: str, **kwargs: Any) -> AgentAdapter: elif engine == "kilocode": from codeframe.core.adapters.kilocode import KilocodeAdapter - return KilocodeAdapter() + return KilocodeAdapter(**kwargs) else: raise ValueError( f"Unknown external engine '{engine}'. " diff --git a/tests/core/adapters/test_kilocode.py b/tests/core/adapters/test_kilocode.py index 973c8ee8..f320fe10 100644 --- a/tests/core/adapters/test_kilocode.py +++ b/tests/core/adapters/test_kilocode.py @@ -1,5 +1,6 @@ """Tests for Kilocode adapter.""" +import shlex from pathlib import Path from unittest.mock import MagicMock, patch @@ -8,6 +9,11 @@ from codeframe.core.adapters.agent_adapter import AgentAdapter from codeframe.core.adapters.kilocode import KilocodeAdapter +pytestmark = pytest.mark.v2 + +_WHICH = "codeframe.core.adapters.subprocess_adapter.shutil.which" +_WHICH_KILOCODE = "codeframe.core.adapters.kilocode.shutil.which" + class TestKilocodeAdapter: """Unit tests for KilocodeAdapter.""" @@ -19,22 +25,22 @@ def _no_git(self): yield def test_name(self) -> None: - with patch("shutil.which", return_value="/usr/bin/kilo"): + with patch(_WHICH, return_value="/usr/bin/kilo"): adapter = KilocodeAdapter() assert adapter.name == "kilocode" def test_conforms_to_protocol(self) -> None: - with patch("shutil.which", return_value="/usr/bin/kilo"): + with patch(_WHICH, return_value="/usr/bin/kilo"): adapter = KilocodeAdapter() assert isinstance(adapter, AgentAdapter) def test_raises_if_kilo_not_installed(self) -> None: - with patch("shutil.which", return_value=None): + with patch(_WHICH, return_value=None): with pytest.raises(EnvironmentError, match="not found on PATH"): KilocodeAdapter() def test_build_command_includes_prompt_and_auto_flag(self) -> None: - with patch("shutil.which", return_value="/usr/bin/kilo"): + with patch(_WHICH, return_value="/usr/bin/kilo"): adapter = KilocodeAdapter() cmd = adapter.build_command("do the thing", Path("/tmp/repo")) assert cmd[0] == "/usr/bin/kilo" @@ -45,22 +51,33 @@ def test_build_command_includes_prompt_and_auto_flag(self) -> None: assert "/tmp/repo" in cmd def test_prompt_is_not_sent_via_stdin(self) -> None: - with patch("shutil.which", return_value="/usr/bin/kilo"): + with patch(_WHICH, return_value="/usr/bin/kilo"): adapter = KilocodeAdapter() assert adapter.get_stdin("my prompt") is None def test_build_command_includes_model_when_env_set(self, monkeypatch) -> None: monkeypatch.setenv("KILOCODE_MODEL", "claude-3-5-sonnet") - with patch("shutil.which", return_value="/usr/bin/kilo"): + with patch(_WHICH, return_value="/usr/bin/kilo"): adapter = KilocodeAdapter() cmd = adapter.build_command("prompt", Path("/tmp/repo")) assert "--model" in cmd idx = cmd.index("--model") assert cmd[idx + 1] == "claude-3-5-sonnet" - def test_build_command_extra_flags_from_env(self, monkeypatch) -> None: + def test_build_command_extra_flags_uses_shlex(self, monkeypatch) -> None: + """KILOCODE_FLAGS must be split with shlex to handle quoted values.""" + monkeypatch.setenv("KILOCODE_FLAGS", '--verbose --log-level "debug mode"') + with patch(_WHICH, return_value="/usr/bin/kilo"): + adapter = KilocodeAdapter() + cmd = adapter.build_command("prompt", Path("/tmp/repo")) + assert "--verbose" in cmd + assert "--log-level" in cmd + # shlex preserves quoted string as a single token + assert "debug mode" in cmd + + def test_build_command_extra_flags_simple(self, monkeypatch) -> None: monkeypatch.setenv("KILOCODE_FLAGS", "--verbose --log-level debug") - with patch("shutil.which", return_value="/usr/bin/kilo"): + with patch(_WHICH, return_value="/usr/bin/kilo"): adapter = KilocodeAdapter() cmd = adapter.build_command("prompt", Path("/tmp/repo")) assert "--verbose" in cmd @@ -69,22 +86,34 @@ def test_build_command_extra_flags_from_env(self, monkeypatch) -> None: def test_custom_binary_from_env(self, monkeypatch) -> None: monkeypatch.setenv("KILOCODE_PATH", "/opt/kilo/bin/kilo") - with patch("shutil.which", return_value="/opt/kilo/bin/kilo"): + with patch(_WHICH, return_value="/opt/kilo/bin/kilo"): adapter = KilocodeAdapter() assert adapter._binary_path == "/opt/kilo/bin/kilo" + def test_resolve_binary_uses_env_var(self, monkeypatch) -> None: + monkeypatch.setenv("KILOCODE_PATH", "/custom/kilo") + assert KilocodeAdapter._resolve_binary() == "/custom/kilo" + + def test_resolve_binary_defaults_to_kilo(self, monkeypatch) -> None: + monkeypatch.delenv("KILOCODE_PATH", raising=False) + assert KilocodeAdapter._resolve_binary() == "kilo" + def test_check_ready_when_binary_present(self) -> None: - with patch("shutil.which", return_value="/usr/bin/kilo"): + with patch(_WHICH_KILOCODE, return_value="/usr/bin/kilo"): result = KilocodeAdapter.check_ready() assert result["kilo_binary"] is True def test_check_ready_when_binary_missing(self) -> None: - with patch("shutil.which", return_value=None): + with patch(_WHICH_KILOCODE, return_value=None): result = KilocodeAdapter.check_ready() assert result["kilo_binary"] is False + def test_requirements_returns_kilocode_path_key(self) -> None: + reqs = KilocodeAdapter.requirements() + assert "KILOCODE_PATH" in reqs + def test_successful_execution(self) -> None: - with patch("shutil.which", return_value="/usr/bin/kilo"): + with patch(_WHICH, return_value="/usr/bin/kilo"): adapter = KilocodeAdapter() mock_process = MagicMock() @@ -102,7 +131,7 @@ def test_successful_execution(self) -> None: assert "Wrote src/foo.py" in result.output def test_failed_execution_nonzero_exit(self) -> None: - with patch("shutil.which", return_value="/usr/bin/kilo"): + with patch(_WHICH, return_value="/usr/bin/kilo"): adapter = KilocodeAdapter() mock_process = MagicMock() @@ -119,7 +148,7 @@ def test_failed_execution_nonzero_exit(self) -> None: assert result.status == "failed" def test_timeout_exit_code_124_maps_to_failed(self) -> None: - with patch("shutil.which", return_value="/usr/bin/kilo"): + with patch(_WHICH, return_value="/usr/bin/kilo"): adapter = KilocodeAdapter() mock_process = MagicMock() @@ -137,7 +166,7 @@ def test_timeout_exit_code_124_maps_to_failed(self) -> None: assert "timed out" in (result.error or "").lower() def test_event_callback_receives_output_lines(self) -> None: - with patch("shutil.which", return_value="/usr/bin/kilo"): + with patch(_WHICH, return_value="/usr/bin/kilo"): adapter = KilocodeAdapter() events: list = [] diff --git a/tests/core/test_engine_registry.py b/tests/core/test_engine_registry.py index a8d6ee35..7f25afb7 100644 --- a/tests/core/test_engine_registry.py +++ b/tests/core/test_engine_registry.py @@ -5,6 +5,8 @@ import pytest from unittest.mock import MagicMock, patch +pytestmark = pytest.mark.v2 + from codeframe.core.adapters.agent_adapter import AgentAdapter from codeframe.core.engine_registry import ( BUILTIN_ENGINES, From f1d50d1ff5c3c40213a630ad1970064cfba57fbb Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 19 Mar 2026 15:52:54 -0700 Subject: [PATCH 3/3] fix(lint): remove unused shlex import; move pytestmark after imports --- tests/core/adapters/test_kilocode.py | 1 - tests/core/test_engine_registry.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/tests/core/adapters/test_kilocode.py b/tests/core/adapters/test_kilocode.py index f320fe10..1d76c656 100644 --- a/tests/core/adapters/test_kilocode.py +++ b/tests/core/adapters/test_kilocode.py @@ -1,6 +1,5 @@ """Tests for Kilocode adapter.""" -import shlex from pathlib import Path from unittest.mock import MagicMock, patch diff --git a/tests/core/test_engine_registry.py b/tests/core/test_engine_registry.py index 7f25afb7..a8d6ee35 100644 --- a/tests/core/test_engine_registry.py +++ b/tests/core/test_engine_registry.py @@ -5,8 +5,6 @@ import pytest from unittest.mock import MagicMock, patch -pytestmark = pytest.mark.v2 - from codeframe.core.adapters.agent_adapter import AgentAdapter from codeframe.core.engine_registry import ( BUILTIN_ENGINES,