feat: 优化运行时判定

This commit is contained in:
richarjiang
2026-09-24 14:27:07 +08:00
parent dfd3998399
commit f0ac603f27
25 changed files with 2368 additions and 58 deletions

176
tests/test_cli.py Normal file
View File

@@ -0,0 +1,176 @@
"""Tests for ``laya_router.cli`` PATH / symlink helpers.
These cover the path exposure logic added so ``laya-router`` and
``laya-hook`` work from any shell without manual ``PATH`` setup.
"""
from __future__ import annotations
import os
from pathlib import Path
from laya_router.cli import (
_apply_path_symlinks,
_bin_link_targets,
_remove_path_symlinks,
_user_path_bin_dir,
ensure_path_symlinks,
remove_path_symlinks,
)
class _FakeSettings:
"""Minimal stand-in for ``Settings`` carrying just the paths we touch."""
def __init__(self, router: Path, hook: Path):
self.router_bin = router
self.hook_bin = hook
self.mcp_bin = router.with_name("laya-mcp")
def _make_fake_binaries(tmp_path: Path) -> tuple[Path, Path, _FakeSettings]:
"""Build two venv-style binaries on disk and return (router, hook, settings)."""
src_dir = tmp_path / "venv_bin"
src_dir.mkdir()
router = src_dir / "laya-router"
hook = src_dir / "laya-hook"
router.write_text("#!/bin/sh\necho router\n")
hook.write_text("#!/bin/sh\necho hook\n")
return router, hook, _FakeSettings(router, hook)
def test_bin_link_targets_returns_both_binaries():
from laya_router.config import load_settings
targets = _bin_link_targets(load_settings())
names = [name for name, _ in targets]
assert names == ["laya-router", "laya-hook", "laya-mcp"]
assert targets[0][1].is_file()
assert targets[1][1].is_file()
def test_user_path_bin_dir_prefers_local_bin(monkeypatch, tmp_path):
monkeypatch.setenv("PATH", f"{tmp_path}:/usr/bin")
preferred = Path.home() / ".local" / "bin"
if preferred.is_dir() and os.access(preferred, os.W_OK):
assert _user_path_bin_dir() == preferred
else:
assert _user_path_bin_dir() == tmp_path
def test_user_path_bin_dir_skips_system_dirs(monkeypatch):
# Force "no writable dir exists" by overriding to a non-existent path.
monkeypatch.setenv("LAYA_ROUTER_BIN_DIR", "/this/path/does/not/exist")
monkeypatch.setenv("PATH", "/usr/local/bin:/usr/bin:/bin:/sbin")
assert _user_path_bin_dir() is None
def test_user_path_bin_dir_respects_env_override(monkeypatch, tmp_path):
monkeypatch.setenv("LAYA_ROUTER_BIN_DIR", str(tmp_path))
assert _user_path_bin_dir() == tmp_path
def test_user_path_bin_dir_env_override_rejects_unwritable(monkeypatch, tmp_path):
monkeypatch.setenv("LAYA_ROUTER_BIN_DIR", "/this/path/does/not/exist")
assert _user_path_bin_dir() is None
def test_apply_path_symlinks_creates_links(tmp_path):
router, hook, settings = _make_fake_binaries(tmp_path)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
_apply_path_symlinks(settings, bin_dir)
assert (bin_dir / "laya-router").is_symlink()
assert (bin_dir / "laya-router").resolve() == router.resolve()
assert (bin_dir / "laya-hook").is_symlink()
assert (bin_dir / "laya-hook").resolve() == hook.resolve()
def test_apply_path_symlinks_is_noop_when_correct(tmp_path):
_, _, settings = _make_fake_binaries(tmp_path)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
_apply_path_symlinks(settings, bin_dir)
first = (bin_dir / "laya-router").lstat()
_apply_path_symlinks(settings, bin_dir)
second = (bin_dir / "laya-router").lstat()
# Re-running on a correct symlink must not rewrite it.
assert first.st_mtime_ns == second.st_mtime_ns
def test_apply_path_symlinks_repairs_wrong_target(tmp_path):
router, _, settings = _make_fake_binaries(tmp_path)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
(bin_dir / "laya-router").symlink_to("/etc/passwd")
_apply_path_symlinks(settings, bin_dir)
assert (bin_dir / "laya-router").resolve() == router.resolve()
def test_apply_path_symlinks_repairs_broken_symlink(tmp_path):
router, _, settings = _make_fake_binaries(tmp_path)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
(bin_dir / "laya-router").symlink_to("/nonexistent/path")
_apply_path_symlinks(settings, bin_dir)
assert (bin_dir / "laya-router").resolve() == router.resolve()
def test_apply_path_symlinks_skips_unrelated_file(tmp_path, capsys):
router, _, settings = _make_fake_binaries(tmp_path)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
existing = bin_dir / "laya-router"
existing.write_text("user's binary")
_apply_path_symlinks(settings, bin_dir)
captured = capsys.readouterr()
assert "已被占用" in captured.err
# The user's file must not be clobbered.
assert existing.read_text() == "user's binary"
# And the other one should still get a fresh symlink.
assert (bin_dir / "laya-hook").is_symlink()
assert (bin_dir / "laya-router").resolve() != router.resolve()
def test_remove_path_symlinks_removes_both(tmp_path):
_, _, settings = _make_fake_binaries(tmp_path)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
_apply_path_symlinks(settings, bin_dir)
assert (bin_dir / "laya-router").is_symlink()
assert (bin_dir / "laya-hook").is_symlink()
_remove_path_symlinks(settings, bin_dir)
assert not (bin_dir / "laya-router").exists()
assert not (bin_dir / "laya-hook").exists()
def test_remove_path_symlinks_idempotent(tmp_path):
_, _, settings = _make_fake_binaries(tmp_path)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
# No symlinks created — remove should still be safe.
_remove_path_symlinks(settings, bin_dir)
_remove_path_symlinks(settings, bin_dir)
def test_public_helpers_warn_when_no_writable_path(monkeypatch, capsys):
monkeypatch.setenv("LAYA_ROUTER_BIN_DIR", "/this/path/does/not/exist")
settings = _FakeSettings(Path("/nonexistent/router"), Path("/nonexistent/hook"))
ensure_path_symlinks(settings)
captured = capsys.readouterr()
assert "没有可写的目录" in captured.err
# remove_path_symlinks is silent in the same situation.
remove_path_symlinks(settings)

View File

@@ -43,6 +43,21 @@ def test_claude_merge_is_idempotent_and_keeps_existing_hooks(tmp_path):
for item in group["hooks"]
]
assert commands == [f'"{tmp_path / "runtime" / ".venv" / "bin" / "laya-hook"}" --client claude']
for event, matcher in (
("PreToolUse", "Bash|Edit|Write|MultiEdit|apply_patch"),
("PostToolUse", "Bash|Edit|Write|MultiEdit|apply_patch|Read|Grep|Glob"),
):
ours = [
group
for group in saved["hooks"][event]
if any("laya-hook" in item.get("command", "") for item in group.get("hooks", []))
]
assert ours[-1]["matcher"] == matcher
assert any(
"laya-hook" in item.get("command", "")
for group in saved["hooks"]["Stop"]
for item in group["hooks"]
)
assert list(path.parent.glob("settings.json.bak-*"))
assert client.uninstall() is True

60
tests/test_evaluate.py Normal file
View File

@@ -0,0 +1,60 @@
from laya_router.config import Settings
from laya_router.evaluate import evaluate_event
from laya_router.mcp import handle_message
from laya_router.trajectory import SessionStore
class _Agent:
def predict(self, state, questions):
answers = {}
for name, spec in questions.items():
if spec["type"] == "noul":
value = 0.9 if name in {"is_stuck", "destructive", "claims_done_without_verification"} else 0.1
answers[name] = {"noul": value}
return {"answers": answers}
def test_stop_blocks_only_once_per_turn(tmp_path):
settings = Settings(home=tmp_path, skills=False)
store = SessionStore()
body = {
"event": "PostToolUse",
"session_id": "s",
"tool_name": "Edit",
"tool_input": {"file_path": "src/app.py"},
"client": "claude",
}
evaluate_event(_Agent(), settings, store, body)
stop = {
"event": "Stop",
"session_id": "s",
"turn_id": "t1",
"client": "codex",
"stop_hook_active": False,
}
first = evaluate_event(_Agent(), settings, store, stop)
assert first["action"] == "block"
second = evaluate_event(_Agent(), settings, store, stop)
assert second["action"] == "silent"
def test_read_is_recorded_without_a_hint(tmp_path):
settings = Settings(home=tmp_path)
outcome = evaluate_event(
_Agent(),
settings,
SessionStore(),
{
"event": "PostToolUse",
"session_id": "s",
"tool_name": "Read",
"tool_input": {"file_path": "src/app.py"},
},
)
assert outcome["action"] == "silent"
def test_mcp_lists_the_judge_tool():
listed = handle_message({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
assert listed["result"]["tools"][0]["name"] == "laya_judge"
assert handle_message({"jsonrpc": "2.0", "method": "notifications/initialized"}) is None

View File

@@ -79,6 +79,52 @@ def test_success_output(monkeypatch, tmp_path):
assert "hint only" in context
def test_pre_tool_deny_and_post_tool_context(monkeypatch, tmp_path):
monkeypatch.setenv("LAYA_ROUTER_HOME", str(tmp_path))
def respond(settings, path, body, timeout):
assert path == "/event"
assert timeout == settings.event_timeout
if body["event"] == "PreToolUse":
return {"action": "deny", "reason": "do not delete", "context": ""}
return {"action": "inject", "context": "replan", "reason": ""}
monkeypatch.setattr("laya_router.hook.post_json", respond)
denied = json.loads(
handle(
{"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "rm -rf /"}},
{},
"claude",
)
)
assert denied["hookSpecificOutput"]["permissionDecision"] == "deny"
posted = json.loads(
handle(
{"hook_event_name": "PostToolUse", "tool_name": "Bash", "tool_input": {"command": "pytest"}},
{},
"codex",
)
)
assert posted["hookSpecificOutput"]["hookEventName"] == "PostToolUse"
assert posted["hookSpecificOutput"]["additionalContext"] == "replan"
def test_stop_block_and_fail_open(monkeypatch, tmp_path):
monkeypatch.setenv("LAYA_ROUTER_HOME", str(tmp_path))
monkeypatch.setattr(
"laya_router.hook.post_json",
lambda settings, path, body, timeout: {"action": "block", "reason": "run pytest"},
)
blocked = json.loads(handle({"hook_event_name": "Stop", "stop_hook_active": False}, {}, "codex"))
assert blocked == {"decision": "block", "reason": "run pytest"}
def boom(settings, path, body, timeout):
raise URLError("connection refused")
monkeypatch.setattr("laya_router.hook.post_json", boom)
assert handle({"hook_event_name": "Stop"}, {}, "claude") == "{}"
def _payload():
return {
"level": "normal",

102
tests/test_policy.py Normal file
View File

@@ -0,0 +1,102 @@
from laya_router.policy import decide_post_tool, decide_pre_tool, decide_stop
def test_post_tool_dedupes_and_escalates_when_stuck():
signals = {"consecutive_failures": 0, "repeated_command": 1}
scores = {"is_stuck": 0.1, "should_replan": 0.1, "needs_more_context": 0.1}
first = decide_post_tool(enabled=True, signals=signals, scores=scores, last_hint="")
assert first.action == "inject"
again = decide_post_tool(enabled=True, signals=signals, scores=scores, last_hint=first.context)
assert again.action == "silent"
stuck = decide_post_tool(
enabled=True,
signals={"consecutive_failures": 2, "repeated_command": 2},
scores={"is_stuck": 0.9},
last_hint="",
)
assert "replan" in stuck.context
def test_gate_never_allows_and_codex_does_not_ask():
quiet = decide_pre_tool(
trajectory_enabled=True,
gate_enabled=True,
client="claude",
tool_name="Bash",
signals={"is_edit": False, "target_unread": False},
edit_scores={},
gate_scores={"destructive": 0.1, "irreversible": 0.1, "touches_secrets_or_prod": 0.1},
)
assert quiet.action == "silent"
medium = decide_pre_tool(
trajectory_enabled=False,
gate_enabled=True,
client="claude",
tool_name="Bash",
signals={},
edit_scores={},
gate_scores={"destructive": 0.6, "irreversible": 0.5, "touches_secrets_or_prod": 0.4},
)
assert medium.action == "ask"
codex = decide_pre_tool(
trajectory_enabled=False,
gate_enabled=True,
client="codex",
tool_name="Bash",
signals={},
edit_scores={},
gate_scores={"destructive": 0.6, "irreversible": 0.5, "touches_secrets_or_prod": 0.4},
)
assert codex.action == "inject"
denied = decide_pre_tool(
trajectory_enabled=False,
gate_enabled=True,
client="codex",
tool_name="Bash",
signals={},
edit_scores={},
gate_scores={"destructive": 0.95, "irreversible": 0.2, "touches_secrets_or_prod": 0.1},
)
assert denied.action == "deny"
assert denied.action != "allow"
def test_unread_edit_injects_hint():
decision = decide_pre_tool(
trajectory_enabled=True,
gate_enabled=False,
client="claude",
tool_name="Edit",
signals={"is_edit": True, "target_unread": True},
edit_scores={"edit_premature": 0.2},
gate_scores={},
)
assert decision.action == "inject"
assert "read" in decision.context
def test_stop_blocks_once_and_respects_the_loop_guard():
scores = {"claims_done_without_verification": 0.8}
signals = {"edited_without_verification": True}
blocked = decide_stop(
enabled=True,
stop_hook_active=False,
already_blocked=False,
signals=signals,
scores=scores,
)
assert blocked.action == "block"
assert decide_stop(
enabled=True,
stop_hook_active=True,
already_blocked=False,
signals=signals,
scores=scores,
).action == "silent"
assert decide_stop(
enabled=True,
stop_hook_active=False,
already_blocked=True,
signals=signals,
scores=scores,
).action == "silent"

76
tests/test_skills.py Normal file
View File

@@ -0,0 +1,76 @@
from laya_router.skills import decide_skill, discover_skills, suggest_skill
def _skill(root, name, description, *, disabled=False, body="Do the documented steps."):
folder = root / name
folder.mkdir(parents=True)
flag = "true" if disabled else "false"
(folder / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {description}\ndisable-model-invocation: {flag}\n---\n{body}\n",
encoding="utf-8",
)
def test_discover_skips_disabled_skills(tmp_path):
home = tmp_path / "home"
_skill(home / ".claude" / "skills", "commit", "Create a git commit")
_skill(home / ".agents" / "skills", "hidden", "Should stay hidden", disabled=True)
found = discover_skills(None, home)
assert [skill.name for skill in found] == ["commit"]
def test_gate_and_fits_thresholds():
assert decide_skill(
ranked_names=["commit", "review"],
gate_scores={"acts_on_user_system": 0.1, "would_follow_documented_procedure": 0.1, "prose_suffices": 0.9},
rerank_choice="commit",
fits=[0.9, 0.1],
) is None
assert decide_skill(
ranked_names=["commit", "review"],
gate_scores={"acts_on_user_system": 0.8, "would_follow_documented_procedure": 0.8, "prose_suffices": 0.1},
rerank_choice="review",
fits=[0.2, 0.1],
) is None
assert decide_skill(
ranked_names=["commit", "review"],
gate_scores={"acts_on_user_system": 0.8, "would_follow_documented_procedure": 0.8, "prose_suffices": 0.1},
rerank_choice="review",
fits=[0.4, 0.7],
) == "review"
class _Agent:
def __init__(self, answers):
self.answers = list(answers)
self.calls = 0
def predict(self, state, questions):
payload = self.answers[self.calls % len(self.answers)]
self.calls += 1
return {"answers": payload}
def test_suggest_skips_a_skill_already_named(tmp_path):
home = tmp_path / "home"
_skill(home / ".claude" / "skills", "commit", "Create a git commit")
skills = discover_skills(None, home)
agent = _Agent(
[
{
"which": {"choice": "commit", "probabilities": {"commit": 0.9}},
"acts_on_user_system": {"noul": 0.9},
"would_follow_documented_procedure": {"noul": 0.9},
"prose_suffices": {"noul": 0.1},
},
{
"which": {"choice": "commit"},
"fits_0": {"noul": 0.8},
},
]
)
first = suggest_skill(agent, skills, "commit these changes", inject="suggest", already=set())
assert first["name"] == "commit"
assert first["mode"] == "suggest"
again = suggest_skill(agent, skills, "commit these changes", inject="suggest", already={"commit"})
assert again is None

44
tests/test_trajectory.py Normal file
View File

@@ -0,0 +1,44 @@
from laya_router.trajectory import (
Step,
consecutive_failures,
edited_without_verification,
repeated_command,
response_failed,
step_from_event,
target_paths,
was_inspected,
)
def test_patch_and_edit_paths():
assert target_paths("Edit", {"file_path": "src/app.py"}) == ("src/app.py",)
patch = "*** Update File: src/app.py\n*** Add File: src/new.py\n"
assert target_paths("apply_patch", {"command": patch}) == ("src/app.py", "src/new.py")
def test_failure_signals():
assert response_failed({"exit_code": 1})
assert not response_failed({"exit_code": 0})
assert response_failed("command failed: boom")
steps = [
Step("bash", "Bash", "pytest", failed=True),
Step("bash", "Bash", "pytest", failed=True),
]
assert consecutive_failures(steps) == 2
assert repeated_command(steps) == 2
steps.append(Step("bash", "Bash", "pytest", failed=False))
assert consecutive_failures(steps) == 0
def test_unread_edit_and_verification():
read = step_from_event("Read", {"file_path": "src/app.py"}, None, after=True)
edit = step_from_event("Edit", {"file_path": "src/app.py"}, None, after=True)
other = step_from_event("Edit", {"file_path": "src/other.py"}, None, after=True)
verify = step_from_event("Bash", {"command": "pytest -q"}, {"exit_code": 0}, after=True)
assert read is not None and edit is not None and other is not None and verify is not None
steps = [read, edit]
assert was_inspected(steps, "src/app.py")
assert not was_inspected(steps, "src/other.py")
assert edited_without_verification([edit, other])
assert not edited_without_verification([edit, verify])
assert step_from_event("Edit", {"file_path": "a.py"}, None, after=False) is None