init
This commit is contained in:
99
tests/test_clients.py
Normal file
99
tests/test_clients.py
Normal file
@@ -0,0 +1,99 @@
|
||||
import json
|
||||
|
||||
from laya_router.clients.base import read_json
|
||||
from laya_router.clients.claude import ClaudeClient
|
||||
from laya_router.clients.codex import CodexClient, hooks_feature_enabled
|
||||
from laya_router.config import Settings
|
||||
|
||||
|
||||
def _settings(tmp_path) -> Settings:
|
||||
return Settings(home=tmp_path)
|
||||
|
||||
|
||||
def test_claude_merge_is_idempotent_and_keeps_existing_hooks(tmp_path):
|
||||
path = tmp_path / "settings.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"env": {"ANTHROPIC_AUTH_TOKEN": "secret-token"},
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [{"type": "command", "command": "rtk hook claude"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
client = ClaudeClient(path)
|
||||
settings = _settings(tmp_path / "runtime")
|
||||
|
||||
assert client.install(settings) is True
|
||||
assert client.install(settings) is False
|
||||
|
||||
saved = read_json(path)
|
||||
assert saved["env"]["ANTHROPIC_AUTH_TOKEN"] == "secret-token"
|
||||
assert saved["hooks"]["PreToolUse"][0]["matcher"] == "Bash"
|
||||
commands = [
|
||||
item["command"]
|
||||
for group in saved["hooks"]["UserPromptSubmit"]
|
||||
for item in group["hooks"]
|
||||
]
|
||||
assert commands == [f'"{tmp_path / "runtime" / ".venv" / "bin" / "laya-hook"}" --client claude']
|
||||
assert list(path.parent.glob("settings.json.bak-*"))
|
||||
|
||||
assert client.uninstall() is True
|
||||
removed = read_json(path)
|
||||
assert "UserPromptSubmit" not in removed["hooks"]
|
||||
assert removed["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == "rtk hook claude"
|
||||
assert removed["env"]["ANTHROPIC_AUTH_TOKEN"] == "secret-token"
|
||||
|
||||
|
||||
def test_codex_uninstall_drops_file_when_it_only_contains_our_hook(tmp_path):
|
||||
codex_dir = tmp_path / ".codex"
|
||||
client = CodexClient(codex_dir)
|
||||
settings = _settings(tmp_path / "runtime")
|
||||
assert client.install(settings) is True
|
||||
assert client.install(settings) is False
|
||||
saved = read_json(client.hooks_path)
|
||||
assert saved["hooks"]["UserPromptSubmit"][0]["hooks"][0]["statusMessage"] == "Laya routing"
|
||||
assert client.uninstall() is True
|
||||
assert not client.hooks_path.exists()
|
||||
|
||||
|
||||
def test_codex_uninstall_keeps_foreign_hooks(tmp_path):
|
||||
codex_dir = tmp_path / ".codex"
|
||||
hooks = codex_dir / "hooks.json"
|
||||
hooks.parent.mkdir()
|
||||
hooks.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{"hooks": [{"type": "command", "command": "other-hook"}]}
|
||||
]
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
client = CodexClient(codex_dir)
|
||||
client.install(_settings(tmp_path / "runtime"))
|
||||
client.uninstall()
|
||||
saved = read_json(hooks)
|
||||
commands = [
|
||||
item["command"]
|
||||
for group in saved["hooks"]["UserPromptSubmit"]
|
||||
for item in group["hooks"]
|
||||
]
|
||||
assert commands == ["other-hook"]
|
||||
|
||||
|
||||
def test_hooks_feature_parser():
|
||||
listing = "hooks stable true\nplugin_hooks removed false\n"
|
||||
assert hooks_feature_enabled(listing) is True
|
||||
assert hooks_feature_enabled("hooks experimental false") is False
|
||||
assert hooks_feature_enabled("no hooks here") is None
|
||||
11
tests/test_feed.py
Normal file
11
tests/test_feed.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import json
|
||||
|
||||
from laya_router.feed import read_routes
|
||||
|
||||
|
||||
def test_read_routes_keeps_the_tail(tmp_path):
|
||||
path = tmp_path / "routes.jsonl"
|
||||
lines = [json.dumps({"ts": str(i), "level": "simple"}) for i in range(5)]
|
||||
path.write_text("\n".join(lines) + "\nbad\n", encoding="utf-8")
|
||||
records = read_routes(path, limit=2)
|
||||
assert [item["ts"] for item in records] == ["3", "4"]
|
||||
90
tests/test_hook.py
Normal file
90
tests/test_hook.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import json
|
||||
from urllib.error import URLError
|
||||
|
||||
from laya_router.hook import handle
|
||||
|
||||
|
||||
def _ok_response():
|
||||
body = {
|
||||
"level": "complex",
|
||||
"level_confidence": 0.81,
|
||||
"needs_plan": 0.9,
|
||||
"needs_repo": 0.72,
|
||||
"needs_web": 0.2,
|
||||
"needs_deep_reasoning": 0.88,
|
||||
}
|
||||
|
||||
class Response:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return json.dumps(body).encode()
|
||||
|
||||
return Response()
|
||||
|
||||
|
||||
def test_cursor_is_a_noop():
|
||||
text = handle({"prompt": "帮我改这个函数"}, {"CURSOR_VERSION": "1.0"}, "claude")
|
||||
assert text == "{}"
|
||||
|
||||
|
||||
def test_slash_command_is_skipped():
|
||||
assert handle({"prompt": "/hooks"}, {}, "codex") == "{}"
|
||||
|
||||
|
||||
def test_filesystem_path_is_not_treated_as_slash_command(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("LAYA_ROUTER_HOME", str(tmp_path))
|
||||
monkeypatch.setattr("laya_router.hook.call_laya", lambda settings, body: _payload())
|
||||
text = handle({"prompt": "/Users/richard/proj 里的入口在哪"}, {}, "codex")
|
||||
assert json.loads(text)["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit"
|
||||
|
||||
|
||||
def test_fail_open_when_server_is_down(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("LAYA_ROUTER_HOME", str(tmp_path))
|
||||
|
||||
def boom(settings, body):
|
||||
raise URLError("connection refused")
|
||||
|
||||
monkeypatch.setattr("laya_router.hook.call_laya", boom)
|
||||
assert handle({"prompt": "解释这个报错"}, {}, "claude") == "{}"
|
||||
assert (tmp_path / "logs" / "hook.log").is_file()
|
||||
|
||||
|
||||
def test_success_output(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("LAYA_ROUTER_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
"laya_router.hook.call_laya",
|
||||
lambda settings, body: {
|
||||
"level": "simple",
|
||||
"level_confidence": 0.66,
|
||||
"needs_plan": 0.1,
|
||||
"needs_repo": 0.2,
|
||||
"needs_web": 0.05,
|
||||
"needs_deep_reasoning": 0.08,
|
||||
},
|
||||
)
|
||||
payload = handle(
|
||||
{"prompt": "把这个变量改名", "session_id": "s1", "cwd": "/tmp/repo"},
|
||||
{},
|
||||
"codex",
|
||||
)
|
||||
parsed = json.loads(payload)
|
||||
context = parsed["hookSpecificOutput"]["additionalContext"]
|
||||
assert "Task complexity: simple" in context
|
||||
assert "uncalibrated" in context
|
||||
assert "hint only" in context
|
||||
|
||||
|
||||
def _payload():
|
||||
return {
|
||||
"level": "normal",
|
||||
"level_confidence": 0.5,
|
||||
"needs_plan": 0.4,
|
||||
"needs_repo": 0.4,
|
||||
"needs_web": 0.1,
|
||||
"needs_deep_reasoning": 0.3,
|
||||
}
|
||||
65
tests/test_result.py
Normal file
65
tests/test_result.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import pytest
|
||||
|
||||
from laya_router.config import load_settings
|
||||
from laya_router.launchd import agent_plist
|
||||
from laya_router.render import is_slash_command, truncate_prompt
|
||||
from laya_router.result import normalize_answers
|
||||
|
||||
|
||||
def test_normalize_answers_reads_choice_and_noul():
|
||||
result = normalize_answers(
|
||||
{
|
||||
"task_level": {
|
||||
"type": "choice",
|
||||
"choice": "normal",
|
||||
"confidence": 0.42,
|
||||
"probabilities": {"simple": 0.2, "normal": 0.5, "complex": 0.3},
|
||||
},
|
||||
"needs_plan": {"type": "noul", "noul": 0.61, "confidence": 0.61},
|
||||
"needs_repo": {"type": "noul", "noul": 0.7, "confidence": 0.7},
|
||||
"needs_web": {"type": "noul", "noul": 0.1, "confidence": 0.9},
|
||||
"needs_deep_reasoning": {"type": "noul", "noul": 0.33, "confidence": 0.67},
|
||||
}
|
||||
)
|
||||
assert result["level"] == "normal"
|
||||
assert result["needs_plan"] == pytest.approx(0.61)
|
||||
assert result["level_probabilities"]["normal"] == pytest.approx(0.5)
|
||||
assert result["questions_version"] == "1"
|
||||
|
||||
|
||||
def test_truncate_keeps_head_and_tail():
|
||||
text = "HEAD" + ("中" * 5000) + "TAIL"
|
||||
clipped = truncate_prompt(text, 1000)
|
||||
assert clipped.startswith("HEAD")
|
||||
assert clipped.endswith("TAIL")
|
||||
assert "[...truncated...]" in clipped
|
||||
assert len(clipped) < len(text)
|
||||
|
||||
|
||||
def test_slash_command_detection():
|
||||
assert is_slash_command("/hooks")
|
||||
assert is_slash_command("/model gpt-5")
|
||||
assert not is_slash_command("/Users/richard/proj 看一下")
|
||||
assert not is_slash_command("帮我 /review 一下")
|
||||
|
||||
|
||||
def test_config_file_and_env(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("LAYA_ROUTER_HOME", str(tmp_path))
|
||||
(tmp_path / "config.toml").write_text('port = 9999\ndevice = "cpu"\n', encoding="utf-8")
|
||||
monkeypatch.setenv("LAYA_ROUTER_PORT", "7777")
|
||||
settings = load_settings()
|
||||
assert settings.port == 7777
|
||||
assert settings.device == "cpu"
|
||||
assert settings.model == "convaiinnovations/laya"
|
||||
|
||||
|
||||
def test_launchd_plist_is_a_single_process_without_reload(tmp_path):
|
||||
settings = load_settings(tmp_path)
|
||||
plist = agent_plist(settings)
|
||||
assert plist["ProgramArguments"] == [str(tmp_path / ".venv" / "bin" / "laya-router"), "serve"]
|
||||
assert "--reload" not in plist["ProgramArguments"]
|
||||
assert plist["EnvironmentVariables"]["USE_TF"] == "0"
|
||||
assert plist["EnvironmentVariables"]["PYTHONUNBUFFERED"] == "1"
|
||||
assert plist["EnvironmentVariables"]["PATH"].endswith("/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
assert plist["KeepAlive"] is True
|
||||
assert plist["ThrottleInterval"] >= 10
|
||||
Reference in New Issue
Block a user