341 lines
10 KiB
Python
341 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from tomlkit import dumps, parse
|
|
|
|
from elydora import cli
|
|
from elydora.plugins import kimi
|
|
from elydora.plugins.registry import SUPPORTED_AGENTS
|
|
from kimi_support import (
|
|
AGENT_ID,
|
|
MISSING,
|
|
assert_managed_triple,
|
|
assert_no_transaction_files,
|
|
legacy_command,
|
|
parsed_hooks,
|
|
prepare_fixture,
|
|
)
|
|
|
|
|
|
STABLE_USER_CONFIG = (
|
|
'# stable user config\ndefault_model = "kimi-code/k3"\n\n'
|
|
'[[hooks]]\nevent = "SessionStart"\ncommand = "existing-stable"\n'
|
|
"timeout = 30 # keep stable hook\n"
|
|
)
|
|
LEGACY_USER_CONFIG = (
|
|
"# legacy user config\ntelemetry = false\n\n"
|
|
'[[hooks]]\nevent = "SessionEnd"\ncommand = "existing-legacy"\n'
|
|
)
|
|
|
|
|
|
def test_kimi_is_registered_in_sdk_and_cli() -> None:
|
|
assert SUPPORTED_AGENTS["kimi"] == {
|
|
"name": "Kimi Code",
|
|
"hook_event": "PreToolUse/PostToolUse/PostToolUseFailure",
|
|
"config_path": "~/.kimi-code/config.toml",
|
|
}
|
|
assert cli.PLUGIN_MAP["kimi"] is kimi.KimiPlugin
|
|
|
|
|
|
def test_install_writes_exact_triples_to_both_configs_and_is_idempotent(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
fixture = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
stable_config=STABLE_USER_CONFIG,
|
|
legacy_config=LEGACY_USER_CONFIG,
|
|
)
|
|
|
|
fixture.install()
|
|
first_sources = {
|
|
path: path.read_text(encoding="utf-8")
|
|
for path in (fixture.stable_path, fixture.legacy_path)
|
|
}
|
|
fixture.install()
|
|
|
|
assert "Kimi Code and kimi-cli" in capsys.readouterr().out
|
|
for path, comment, command in (
|
|
(fixture.stable_path, "# stable user config", "existing-stable"),
|
|
(fixture.legacy_path, "# legacy user config", "existing-legacy"),
|
|
):
|
|
raw = path.read_text(encoding="utf-8")
|
|
hooks = parsed_hooks(path)
|
|
assert raw == first_sources[path]
|
|
assert comment in raw
|
|
assert any(hook.get("command") == command for hook in hooks)
|
|
assert len(hooks) == 4
|
|
assert_managed_triple(hooks)
|
|
assert not (fixture.home_dir / ".kimi-code" / "config.toml").exists()
|
|
|
|
|
|
def test_install_preserves_inline_hook_array_style(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
source = (
|
|
'# inline user hook\nhooks = [{ event = "SessionStart", '
|
|
'command = "existing-inline" }] # keep array\n'
|
|
)
|
|
fixture = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
stable_config=source,
|
|
legacy_detected=False,
|
|
)
|
|
|
|
fixture.install()
|
|
fixture.install()
|
|
|
|
raw = fixture.stable_path.read_text(encoding="utf-8")
|
|
assert "# inline user hook" in raw
|
|
assert "# keep array" in raw
|
|
assert "hooks = [" in raw
|
|
hooks = parsed_hooks(fixture.stable_path)
|
|
assert len(hooks) == 4
|
|
assert_managed_triple(hooks)
|
|
|
|
|
|
def test_uninstall_preserves_user_owned_empty_inline_hook_array(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
source = "# user container\nhooks = [] # keep empty array\n"
|
|
fixture = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
stable_config=source,
|
|
legacy_detected=False,
|
|
)
|
|
fixture.install()
|
|
fixture.plugin.uninstall(AGENT_ID)
|
|
|
|
raw = fixture.stable_path.read_text(encoding="utf-8")
|
|
assert "# user container" in raw
|
|
assert "# keep empty array" in raw
|
|
assert parse(raw)["hooks"].unwrap() == []
|
|
|
|
|
|
def test_fresh_stable_install_leaves_legacy_source_absent(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path, legacy_detected=False)
|
|
fixture.install()
|
|
|
|
hooks = parsed_hooks(fixture.stable_path)
|
|
assert len(hooks) == 3
|
|
assert_managed_triple(hooks)
|
|
assert not fixture.legacy_path.exists()
|
|
|
|
|
|
def test_empty_home_override_uses_documented_default(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
fixture = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
explicit_kimi_home=False,
|
|
legacy_detected=False,
|
|
)
|
|
fixture.install()
|
|
monkeypatch.setenv("KIMI_CODE_HOME", "")
|
|
|
|
assert fixture.plugin.status()["installed"] is True
|
|
assert_managed_triple(parsed_hooks(fixture.stable_path))
|
|
|
|
|
|
def test_legacy_only_install_leaves_stable_target_absent(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
fixture = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
explicit_kimi_home=False,
|
|
stable_detected=False,
|
|
)
|
|
fixture.install()
|
|
|
|
assert_managed_triple(parsed_hooks(fixture.legacy_path))
|
|
assert not fixture.stable_path.exists()
|
|
assert fixture.plugin.status()["installed"] is True
|
|
|
|
|
|
def test_install_parses_every_selected_config_before_runtime_writes(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
fixture = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
stable_config=STABLE_USER_CONFIG,
|
|
legacy_config="[malformed",
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="parse kimi-cli legacy hooks config"):
|
|
fixture.install()
|
|
|
|
assert fixture.stable_path.read_text(encoding="utf-8") == STABLE_USER_CONFIG
|
|
assert fixture.legacy_path.read_text(encoding="utf-8") == "[malformed"
|
|
assert not fixture.agent_dir.exists()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("stable_config", "legacy_config", "pattern"),
|
|
[
|
|
(
|
|
'[[hooks]]\nevent = "PreToolUse"\ncommand = "existing"\n'
|
|
'cwd = "/tmp"\n',
|
|
None,
|
|
'unsupported field "cwd"',
|
|
),
|
|
(
|
|
None,
|
|
'[[hooks]]\nevent = "Interrupt"\ncommand = "existing"\n',
|
|
'unsupported event "Interrupt"',
|
|
),
|
|
(
|
|
'[[hooks]]\nevent = "PreToolUse"\ncommand = "existing"\n'
|
|
"timeout = 601\n",
|
|
None,
|
|
"integer from 1 to 600",
|
|
),
|
|
],
|
|
)
|
|
def test_install_rejects_fields_and_events_outside_each_contract(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
stable_config: str | None,
|
|
legacy_config: str | None,
|
|
pattern: str,
|
|
) -> None:
|
|
fixture = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
stable_config=stable_config if stable_config is not None else MISSING,
|
|
legacy_config=legacy_config if legacy_config is not None else MISSING,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match=pattern):
|
|
fixture.install()
|
|
|
|
assert not fixture.agent_dir.exists()
|
|
|
|
|
|
def test_install_migrates_exact_legacy_commands_and_preserves_lookalikes(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path, legacy_detected=False)
|
|
lookalike = f"{legacy_command(fixture.guard_path)} --inspect"
|
|
source = (
|
|
'[[hooks]]\nevent = "PreToolUse"\n'
|
|
f"command = {json.dumps(legacy_command(fixture.guard_path))}\n"
|
|
"timeout = 10\n\n"
|
|
'[[hooks]]\nevent = "PostToolUse"\n'
|
|
f"command = {json.dumps(legacy_command(fixture.hook_path))}\n"
|
|
"timeout = 10\n\n"
|
|
'[[hooks]]\nevent = "PreToolUse"\n'
|
|
f"command = {json.dumps(lookalike)}\ntimeout = 10\n"
|
|
)
|
|
fixture.stable_path.parent.mkdir(parents=True, exist_ok=True)
|
|
fixture.stable_path.write_text(source, encoding="utf-8")
|
|
|
|
fixture.install()
|
|
|
|
hooks = parsed_hooks(fixture.stable_path)
|
|
assert len(hooks) == 4
|
|
assert any(hook.get("command") == lookalike for hook in hooks)
|
|
assert_managed_triple(hooks)
|
|
|
|
fixture.plugin.uninstall(AGENT_ID)
|
|
assert parsed_hooks(fixture.stable_path) == [
|
|
{"event": "PreToolUse", "command": lookalike, "timeout": 10}
|
|
]
|
|
|
|
|
|
def test_uninstall_preserves_user_configs_and_removes_managed_configs(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
fixture = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
stable_config=STABLE_USER_CONFIG,
|
|
legacy_config=LEGACY_USER_CONFIG,
|
|
)
|
|
fixture.install()
|
|
fixture.plugin.uninstall(AGENT_ID)
|
|
|
|
assert "# keep stable hook" in fixture.stable_path.read_text(encoding="utf-8")
|
|
assert "# legacy user config" in fixture.legacy_path.read_text(encoding="utf-8")
|
|
assert [hook["command"] for hook in parsed_hooks(fixture.stable_path)] == [
|
|
"existing-stable"
|
|
]
|
|
assert [hook["command"] for hook in parsed_hooks(fixture.legacy_path)] == [
|
|
"existing-legacy"
|
|
]
|
|
|
|
managed = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path / "managed",
|
|
)
|
|
managed.install()
|
|
managed.plugin.uninstall(AGENT_ID)
|
|
assert not managed.stable_path.exists()
|
|
assert not managed.legacy_path.exists()
|
|
|
|
|
|
def test_status_requires_complete_triple_runtime_identity_and_private_key(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path, legacy_detected=False)
|
|
fixture.install()
|
|
assert fixture.plugin.status()["installed"] is True
|
|
|
|
document = parse(fixture.stable_path.read_text(encoding="utf-8"))
|
|
hooks = document["hooks"]
|
|
for index in range(len(hooks) - 1, -1, -1):
|
|
if hooks[index]["event"] == "PostToolUseFailure":
|
|
del hooks[index]
|
|
fixture.stable_path.write_text(dumps(document), encoding="utf-8")
|
|
assert fixture.plugin.status()["installed"] is False
|
|
|
|
fixture.install()
|
|
fixture.private_key_path.write_text("invalid", encoding="utf-8")
|
|
with pytest.raises(ValueError, match="private key.*canonical 32-byte"):
|
|
fixture.plugin.status()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"runtime_name",
|
|
["guard.py", "hook.py", "config.json", "private.key"],
|
|
)
|
|
def test_status_requires_every_runtime_file(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
runtime_name: str,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path, legacy_detected=False)
|
|
fixture.install()
|
|
(fixture.agent_dir / runtime_name).unlink()
|
|
|
|
assert fixture.plugin.status()["installed"] is False
|
|
|
|
|
|
def test_installation_leaves_no_transaction_files(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
fixture.install()
|
|
|
|
assert_no_transaction_files(fixture.home_dir)
|
|
if os.name != "nt":
|
|
for path in (
|
|
fixture.stable_path,
|
|
fixture.legacy_path,
|
|
fixture.runtime_config_path,
|
|
fixture.private_key_path,
|
|
):
|
|
assert path.stat().st_mode & 0o777 == 0o600
|