4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
393 lines
14 KiB
Python
393 lines
14 KiB
Python
"""Tests for Kimi Code CLI adapter."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from integrations.llm_cli.kimi import KimiAdapter
|
|
|
|
|
|
def _version_proc() -> MagicMock:
|
|
m = MagicMock()
|
|
m.returncode = 0
|
|
m.stdout = "kimi-cli version: 1.40.0\n"
|
|
m.stderr = ""
|
|
return m
|
|
|
|
|
|
def _login_status_logged_in_proc() -> MagicMock:
|
|
"""Mock response for 'kimi login status' when logged in."""
|
|
m = MagicMock()
|
|
m.returncode = 0
|
|
m.stdout = "You are logged in.\n"
|
|
m.stderr = ""
|
|
return m
|
|
|
|
|
|
@patch("integrations.llm_cli.kimi.subprocess.run")
|
|
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
|
def test_detect_path_binary_logged_in_env(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
|
mock_which.return_value = "/usr/bin/kimi"
|
|
|
|
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
|
if len(args) >= 2 and args[1] == "--version":
|
|
return _version_proc()
|
|
elif len(args) >= 3 and args[1] == "login" and args[2] == "status":
|
|
return _login_status_logged_in_proc()
|
|
raise AssertionError(f"Unexpected call: {args}")
|
|
|
|
mock_run.side_effect = side_effect
|
|
with patch.dict(os.environ, {"KIMI_API_KEY": "sk-test"}, clear=False):
|
|
probe = KimiAdapter().detect()
|
|
|
|
assert probe.installed is True
|
|
assert probe.logged_in is True
|
|
assert probe.bin_path == "/usr/bin/kimi"
|
|
assert probe.version == "1.40.0"
|
|
|
|
|
|
@patch("integrations.llm_cli.kimi.subprocess.run")
|
|
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
|
def test_detect_min_version_enforcement(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
|
mock_which.return_value = "/usr/bin/kimi"
|
|
|
|
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
|
if len(args) >= 2 and args[1] == "--version":
|
|
m = MagicMock()
|
|
m.returncode = 0
|
|
m.stdout = "kimi-cli version: 1.39.0\n"
|
|
m.stderr = ""
|
|
return m
|
|
elif len(args) >= 3 and args[1] == "login" and args[2] == "status":
|
|
# When version is below minimum, login status might still succeed
|
|
m = MagicMock()
|
|
m.returncode = 0
|
|
m.stdout = "You are logged in.\n"
|
|
m.stderr = ""
|
|
return m
|
|
raise AssertionError(f"Unexpected call: {args}")
|
|
|
|
mock_run.side_effect = side_effect
|
|
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
probe = KimiAdapter().detect()
|
|
|
|
assert probe.installed is True
|
|
assert probe.version == "1.39.0"
|
|
assert "upgrade: uv tool upgrade kimi-cli" in probe.detail
|
|
|
|
|
|
@patch("integrations.llm_cli.kimi.pathlib.Path.exists", return_value=False)
|
|
@patch("integrations.llm_cli.kimi.subprocess.run")
|
|
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
|
def test_detect_missing_config_not_logged_in(
|
|
mock_which: MagicMock, mock_run: MagicMock, _mock_exists: MagicMock
|
|
) -> None:
|
|
mock_which.return_value = "/usr/bin/kimi"
|
|
|
|
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
|
if len(args) >= 2 and args[1] == "--version":
|
|
return _version_proc()
|
|
elif len(args) >= 3 and args[1] == "login" and args[2] == "status":
|
|
# Not logged in case
|
|
m = MagicMock()
|
|
m.returncode = 1
|
|
m.stdout = ""
|
|
m.stderr = "Not logged in."
|
|
return m
|
|
raise AssertionError(f"Unexpected call: {args}")
|
|
|
|
mock_run.side_effect = side_effect
|
|
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
probe = KimiAdapter().detect()
|
|
|
|
assert probe.installed is True
|
|
assert probe.logged_in is False
|
|
|
|
|
|
@patch("integrations.llm_cli.kimi.subprocess.run")
|
|
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
|
def test_detect_login_status_not_logged_in_uses_api_key_fallback(
|
|
mock_which: MagicMock, mock_run: MagicMock
|
|
) -> None:
|
|
"""When kimi login status reports not logged in, check KIMI_API_KEY fallback."""
|
|
mock_which.return_value = "/usr/bin/kimi"
|
|
|
|
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
|
if len(args) >= 2 and args[1] == "--version":
|
|
return _version_proc()
|
|
if len(args) >= 3 and args[1] == "login" and args[2] == "status":
|
|
m = MagicMock()
|
|
m.returncode = 1
|
|
m.stdout = ""
|
|
m.stderr = "Not logged in."
|
|
return m
|
|
raise AssertionError(f"Unexpected call: {args}")
|
|
|
|
mock_run.side_effect = side_effect
|
|
|
|
with patch.dict(os.environ, {"KIMI_API_KEY": " sk-test "}, clear=True):
|
|
probe = KimiAdapter().detect()
|
|
|
|
assert probe.installed is True
|
|
assert probe.logged_in is True
|
|
assert "KIMI_API_KEY" in probe.detail
|
|
assert mock_run.call_args_list[0].args[0] == ["/usr/bin/kimi", "--version"]
|
|
assert mock_run.call_args_list[1].args[0] == ["/usr/bin/kimi", "login", "status"]
|
|
|
|
|
|
@patch("integrations.llm_cli.kimi.pathlib.Path.read_text")
|
|
@patch("integrations.llm_cli.kimi.pathlib.Path.exists", return_value=True)
|
|
@patch("integrations.llm_cli.kimi.subprocess.run")
|
|
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
|
def test_detect_login_status_unavailable_uses_config_fallback(
|
|
mock_which: MagicMock,
|
|
mock_run: MagicMock,
|
|
_mock_exists: MagicMock,
|
|
mock_read_text: MagicMock,
|
|
) -> None:
|
|
mock_which.return_value = "/usr/bin/kimi"
|
|
mock_read_text.return_value = '[providers.moonshot]\napi_key = "sk-config"\n'
|
|
|
|
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
|
if len(args) >= 2 and args[1] == "--version":
|
|
return _version_proc()
|
|
if len(args) >= 3 and args[1] == "login" and args[2] == "status":
|
|
m = MagicMock()
|
|
m.returncode = 2
|
|
m.stdout = ""
|
|
m.stderr = "unknown command: login"
|
|
return m
|
|
raise AssertionError(f"Unexpected call: {args}")
|
|
|
|
mock_run.side_effect = side_effect
|
|
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
probe = KimiAdapter().detect()
|
|
|
|
assert probe.installed is True
|
|
assert probe.logged_in is True
|
|
assert "config.toml" in probe.detail
|
|
|
|
|
|
@patch(
|
|
"integrations.llm_cli.binary_resolver.shutil.which",
|
|
return_value="/usr/bin/kimi",
|
|
)
|
|
def test_build_adds_model_flag_and_yolo(mock_which: MagicMock) -> None:
|
|
inv = KimiAdapter().build(prompt="p", model="kimi-k2.5", workspace="")
|
|
assert inv.stdin == "p"
|
|
assert "--yolo" in inv.argv
|
|
assert "--print" in inv.argv
|
|
assert "-m" in inv.argv
|
|
idx = inv.argv.index("-m")
|
|
assert inv.argv[idx + 1] == "kimi-k2.5"
|
|
mock_which.assert_called()
|
|
|
|
|
|
def test_kimi_cli_registry_entry() -> None:
|
|
from integrations.llm_cli.registry import get_cli_provider_registration
|
|
|
|
reg = get_cli_provider_registration("kimi")
|
|
assert reg is not None
|
|
assert reg.model_env_key == "KIMI_MODEL"
|
|
assert reg.adapter_factory().name == "kimi"
|
|
|
|
|
|
@patch("integrations.llm_cli.runner.subprocess.run")
|
|
def test_cli_backed_client_invoke_forwards_kimi_env(mock_run: MagicMock) -> None:
|
|
from integrations.llm_cli.kimi import KimiAdapter
|
|
from integrations.llm_cli.runner import CLIBackedLLMClient
|
|
|
|
mock_adapter = MagicMock(spec=KimiAdapter)
|
|
mock_adapter.name = "kimi"
|
|
mock_adapter.detect.return_value = MagicMock(
|
|
installed=True,
|
|
bin_path="/usr/bin/kimi",
|
|
logged_in=True,
|
|
detail="ok",
|
|
)
|
|
mock_adapter.build.return_value = MagicMock(
|
|
argv=["/usr/bin/kimi", "--print", "--yolo"],
|
|
stdin="hello",
|
|
cwd="/tmp",
|
|
env=None,
|
|
timeout_sec=30.0,
|
|
)
|
|
mock_adapter.parse.return_value = "answer"
|
|
mock_adapter.explain_failure.return_value = "fail"
|
|
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="answer\n", stderr="")
|
|
|
|
with (
|
|
patch("platform.guardrails.engine.get_guardrail_engine") as gr,
|
|
patch.dict(
|
|
os.environ,
|
|
{
|
|
"KIMI_API_KEY": "sk-kimi",
|
|
"KIMI_BASE_URL": "https://custom.kimi.com",
|
|
"OPENAI_API_KEY": "sk-openai",
|
|
},
|
|
clear=False,
|
|
),
|
|
):
|
|
gr.return_value.is_active = False
|
|
client = CLIBackedLLMClient(mock_adapter, model="kimi-k2.5", max_tokens=256)
|
|
resp = client.invoke("hello")
|
|
|
|
assert resp.content == "answer"
|
|
env = mock_run.call_args.kwargs["env"]
|
|
assert env["KIMI_API_KEY"] == "sk-kimi"
|
|
assert env["KIMI_BASE_URL"] == "https://custom.kimi.com"
|
|
assert "OPENAI_API_KEY" not in env
|
|
|
|
|
|
@patch("integrations.llm_cli.runner.time.sleep")
|
|
@patch("integrations.llm_cli.runner.subprocess.run")
|
|
def test_cli_backed_client_retries_on_ex_tempfail(
|
|
mock_run: MagicMock, mock_sleep: MagicMock
|
|
) -> None:
|
|
"""EX_TEMPFAIL (75) should be retried; on final success returns the answer."""
|
|
from integrations.llm_cli.kimi import KimiAdapter
|
|
from integrations.llm_cli.runner import CLIBackedLLMClient
|
|
|
|
mock_adapter = MagicMock(spec=KimiAdapter)
|
|
mock_adapter.name = "kimi"
|
|
mock_adapter.detect.return_value = MagicMock(
|
|
installed=True,
|
|
bin_path="/usr/bin/kimi",
|
|
logged_in=True,
|
|
detail="ok",
|
|
)
|
|
mock_adapter.build.return_value = MagicMock(
|
|
argv=["/usr/bin/kimi", "--print", "--yolo"],
|
|
stdin="hello",
|
|
cwd="/tmp",
|
|
env=None,
|
|
timeout_sec=30.0,
|
|
)
|
|
mock_adapter.parse.return_value = "answer"
|
|
mock_adapter.explain_failure.return_value = (
|
|
"kimi exited with code 75. To resume this session: kimi -r abc"
|
|
)
|
|
|
|
tempfail = MagicMock(returncode=75, stdout="To resume this session: kimi -r abc", stderr="")
|
|
success = MagicMock(returncode=0, stdout="answer\n", stderr="")
|
|
mock_run.side_effect = [tempfail, success]
|
|
|
|
with patch("platform.guardrails.engine.get_guardrail_engine") as gr:
|
|
gr.return_value.is_active = False
|
|
client = CLIBackedLLMClient(mock_adapter, model="kimi-k2.5", max_tokens=256)
|
|
resp = client.invoke("hello")
|
|
|
|
assert resp.content == "answer"
|
|
assert mock_run.call_count == 2
|
|
mock_sleep.assert_called_once()
|
|
|
|
|
|
@patch("integrations.llm_cli.runner.time.sleep")
|
|
@patch("integrations.llm_cli.runner.subprocess.run")
|
|
def test_cli_backed_client_raises_after_all_tempfail_retries(
|
|
mock_run: MagicMock, mock_sleep: MagicMock
|
|
) -> None:
|
|
"""EX_TEMPFAIL (75) exhausting all retries raises RuntimeError."""
|
|
import pytest
|
|
|
|
from integrations.llm_cli.kimi import KimiAdapter
|
|
from integrations.llm_cli.runner import _TEMPFAIL_MAX_RETRIES, CLIBackedLLMClient
|
|
|
|
mock_adapter = MagicMock(spec=KimiAdapter)
|
|
mock_adapter.name = "kimi"
|
|
mock_adapter.detect.return_value = MagicMock(
|
|
installed=True,
|
|
bin_path="/usr/bin/kimi",
|
|
logged_in=True,
|
|
detail="ok",
|
|
)
|
|
mock_adapter.build.return_value = MagicMock(
|
|
argv=["/usr/bin/kimi", "--print", "--yolo"],
|
|
stdin="hello",
|
|
cwd="/tmp",
|
|
env=None,
|
|
timeout_sec=30.0,
|
|
)
|
|
mock_adapter.explain_failure.return_value = (
|
|
"kimi exited with code 75. To resume this session: kimi -r abc"
|
|
)
|
|
|
|
tempfail = MagicMock(returncode=75, stdout="To resume this session: kimi -r abc", stderr="")
|
|
mock_run.side_effect = [tempfail] * (_TEMPFAIL_MAX_RETRIES + 1)
|
|
|
|
with patch("platform.guardrails.engine.get_guardrail_engine") as gr:
|
|
gr.return_value.is_active = False
|
|
client = CLIBackedLLMClient(mock_adapter, model="kimi-k2.5", max_tokens=256)
|
|
with pytest.raises(RuntimeError):
|
|
client.invoke("hello")
|
|
|
|
assert mock_run.call_count == _TEMPFAIL_MAX_RETRIES + 1
|
|
assert mock_sleep.call_count == _TEMPFAIL_MAX_RETRIES
|
|
|
|
|
|
def test_parse_and_explain_failure() -> None:
|
|
adapter = KimiAdapter()
|
|
|
|
# Test parse
|
|
assert adapter.parse(stdout=" hello world \n", stderr="", returncode=0) == "hello world"
|
|
|
|
import pytest
|
|
|
|
with pytest.raises(RuntimeError, match="empty output"):
|
|
adapter.parse(stdout=" ", stderr="", returncode=0)
|
|
|
|
# Test explain_failure: Auth error
|
|
fail_auth = adapter.explain_failure(stdout="LLM not set", stderr="", returncode=1)
|
|
assert "Not logged in" in fail_auth
|
|
assert "kimi login" in fail_auth
|
|
|
|
fail_401 = adapter.explain_failure(stdout="", stderr="Error code: 401", returncode=1)
|
|
assert "API key invalid" in fail_401
|
|
|
|
# Test explain_failure: generic error
|
|
fail_generic = adapter.explain_failure(stdout="", stderr="some error", returncode=1)
|
|
assert "kimi exited with code 1" in fail_generic
|
|
assert "some error" in fail_generic
|
|
|
|
# Test explain_failure: empty output with code 0
|
|
fail_empty = adapter.explain_failure(stdout="", stderr="", returncode=0)
|
|
assert fail_empty == "kimi returned no output"
|
|
|
|
|
|
@patch("integrations.llm_cli.runner.subprocess.run")
|
|
def test_cli_backed_client_exit_75_raises_cli_timeout_error(mock_run: MagicMock) -> None:
|
|
"""Exit code 75 (EX_TEMPFAIL) must raise CLITimeoutError, not RuntimeError.
|
|
|
|
Sentry ignores CLITimeoutError so transient kimi failures do not create
|
|
spurious bug reports.
|
|
"""
|
|
import pytest
|
|
|
|
from integrations.llm_cli.kimi import KimiAdapter
|
|
from integrations.llm_cli.runner import CLIBackedLLMClient, CLITimeoutError
|
|
|
|
mock_adapter = MagicMock(spec=KimiAdapter)
|
|
mock_adapter.name = "kimi"
|
|
mock_adapter.detect.return_value = MagicMock(
|
|
installed=True, bin_path="/usr/bin/kimi", logged_in=True, detail="ok"
|
|
)
|
|
mock_adapter.build.return_value = MagicMock(
|
|
argv=["/usr/bin/kimi", "--print", "--yolo"],
|
|
stdin="hello",
|
|
cwd="/tmp",
|
|
env=None,
|
|
timeout_sec=30.0,
|
|
)
|
|
mock_run.return_value = MagicMock(returncode=75, stdout="", stderr="rate limit")
|
|
|
|
with patch("platform.guardrails.engine.get_guardrail_engine") as gr:
|
|
gr.return_value.is_active = False
|
|
client = CLIBackedLLMClient(mock_adapter, model="kimi-k2.5", max_tokens=256)
|
|
with pytest.raises(CLITimeoutError, match="exit 75"):
|
|
client.invoke("hello")
|