Implement CYA-WP-0008 llm-connect adapter integration.

Wire LLMConnectAdapter behind the existing LLMAdapter seam with config-driven
selection, graceful degradation, --offline mode, and bounded session context.
Add unit tests, integration docs, and update README/SCOPE/AGENTS.
This commit is contained in:
2026-06-22 10:36:10 +02:00
parent cd5db14fbf
commit 019f6e7dc7
17 changed files with 800 additions and 19 deletions

View File

@@ -0,0 +1,111 @@
"""LLMConnectAdapter unit tests with mocked llm-connect (CYA-WP-0008)."""
from unittest.mock import MagicMock, patch
import pytest
from cya.config import LLMSettings
from cya.llm.adapter import AssistanceRequest
from cya.llm.connect_adapter import LLMConnectAdapter
def _mock_llm_response(content: str = "Try: git status"):
resp = MagicMock()
resp.content = content
resp.model = "mock/model"
resp.usage = {"total_tokens": 42}
resp.finish_reason = "stop"
return resp
@patch("llm_connect.create_adapter")
@patch("llm_connect.config.resolve_api_key", return_value="test-key")
def test_complete_delegates_to_llm_connect(mock_resolve, mock_create):
client = MagicMock()
client.execute_prompt.return_value = _mock_llm_response()
mock_create.return_value = client
settings = LLMSettings(adapter="connect", backend="mock", model="mock/model", configured=True)
adapter = LLMConnectAdapter(settings)
response = adapter.complete(
AssistanceRequest(user_request="show git status", context={"cwd": "/tmp"})
)
assert "git status" in response.suggestion.lower()
assert response.metadata.get("adapter") == "LLMConnectAdapter"
assert response.metadata.get("degraded") is not True
client.execute_prompt.assert_called_once()
def test_graceful_degrade_when_llm_connect_missing(monkeypatch):
import builtins
real_import = builtins.__import__
def _import(name, *args, **kwargs):
if name == "llm_connect" or name.startswith("llm_connect."):
raise ImportError("no llm_connect")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _import)
settings = LLMSettings(adapter="connect", backend="openrouter", configured=True)
adapter = LLMConnectAdapter(settings)
response = adapter.complete(AssistanceRequest(user_request="hello"))
assert response.metadata.get("degraded") is True
assert "llm-connect" in response.suggestion
@patch("llm_connect.create_adapter")
@patch("llm_connect.config.resolve_api_key", return_value=None)
def test_graceful_degrade_when_api_key_missing(mock_resolve, mock_create):
settings = LLMSettings(adapter="connect", backend="openrouter", configured=True)
adapter = LLMConnectAdapter(settings)
response = adapter.complete(AssistanceRequest(user_request="hello"))
assert response.metadata.get("degraded") is True
assert "API key" in response.suggestion
mock_create.assert_not_called()
@patch("llm_connect.create_adapter")
@patch("llm_connect.config.resolve_api_key", return_value="test-key")
def test_session_turns_included_in_prompt(mock_resolve, mock_create):
client = MagicMock()
client.execute_prompt.return_value = _mock_llm_response("ok")
mock_create.return_value = client
settings = LLMSettings(adapter="connect", backend="mock", configured=True)
adapter = LLMConnectAdapter(settings)
adapter.complete(
AssistanceRequest(
user_request="follow up",
context={
"session_turns": [{"user": "first", "assistant": "reply"}],
},
)
)
_prompt_arg, _ = client.execute_prompt.call_args[0]
assert "first" in _prompt_arg
assert "follow up" in _prompt_arg
@pytest.mark.llm_live
def test_live_openrouter_smoke():
"""Manual verification only — skipped unless OPENROUTER_API_KEY is set."""
import os
if not os.environ.get("OPENROUTER_API_KEY"):
pytest.skip("OPENROUTER_API_KEY not set")
settings = LLMSettings(
adapter="connect",
backend="openrouter",
model="anthropic/claude-sonnet-4",
configured=True,
)
adapter = LLMConnectAdapter(settings)
response = adapter.complete(AssistanceRequest(user_request="Reply with exactly: pong"))
assert response.metadata.get("degraded") is not True
assert response.suggestion

67
tests/test_llm_factory.py Normal file
View File

@@ -0,0 +1,67 @@
"""Adapter factory and config resolution (CYA-WP-0008)."""
import os
from pathlib import Path
import pytest
from cya.config import bound_session_turns, load_llm_settings
from cya.llm.adapter import FakeLLMAdapter
from cya.llm.connect_adapter import LLMConnectAdapter
from cya.llm.factory import get_adapter
def test_default_adapter_is_fake(monkeypatch):
monkeypatch.delenv("CYA_LLM_ADAPTER", raising=False)
monkeypatch.setattr("cya.config._USER_CONFIG", Path("/nonexistent/config.toml"))
adapter = get_adapter()
assert isinstance(adapter, FakeLLMAdapter)
def test_offline_forces_fake(monkeypatch, tmp_path):
cfg = tmp_path / "config.toml"
cfg.write_text('[llm]\nadapter = "connect"\nbackend = "openrouter"\n')
monkeypatch.setattr("cya.config._USER_CONFIG", cfg)
adapter = get_adapter(offline=True)
assert isinstance(adapter, FakeLLMAdapter)
def test_connect_adapter_when_configured(monkeypatch, tmp_path):
cfg = tmp_path / "config.toml"
cfg.write_text('[llm]\nadapter = "connect"\nbackend = "mock"\n')
monkeypatch.setattr("cya.config._USER_CONFIG", cfg)
adapter = get_adapter()
assert isinstance(adapter, LLMConnectAdapter)
def test_env_adapter_override(monkeypatch):
monkeypatch.setenv("CYA_LLM_ADAPTER", "connect")
monkeypatch.setenv("CYA_LLM_BACKEND", "mock")
adapter = get_adapter()
assert isinstance(adapter, LLMConnectAdapter)
def test_load_llm_settings_merges_project_config(monkeypatch, tmp_path):
user_cfg = tmp_path / "user.toml"
user_cfg.write_text('[llm]\nbackend = "openrouter"\nmodel = "from-user"\n')
project_cfg = tmp_path / ".cya.toml"
project_cfg.write_text('[llm]\nmodel = "from-project"\n')
monkeypatch.setattr("cya.config._USER_CONFIG", user_cfg)
monkeypatch.setattr("cya.config._find_project_config", lambda start=None: project_cfg)
settings = load_llm_settings()
assert settings.backend == "openrouter"
assert settings.model == "from-project"
assert settings.adapter == "connect"
def test_bound_session_turns_limits():
turns = [
{"user": "a" * 1000, "assistant": "b" * 1000},
{"user": "c" * 1000, "assistant": "d" * 1000},
{"user": "e", "assistant": "f"},
]
bounded = bound_session_turns(turns, max_turns=10, max_chars=2500)
assert len(bounded) >= 1
total = sum(len(t["user"]) + len(t["assistant"]) for t in bounded)
assert total <= 2500 or len(bounded) == 1

22
tests/test_llm_prompt.py Normal file
View File

@@ -0,0 +1,22 @@
"""Prompt builder tests."""
from cya.llm.adapter import AssistanceRequest
from cya.llm.prompt import build_assistance_prompt
def test_build_assistance_prompt_includes_context_and_request():
system, user = build_assistance_prompt(
AssistanceRequest(
user_request="list files",
context={
"cwd": "/home/user/proj",
"session_turns": [{"user": "hi", "assistant": "hello"}],
"memory": {"items": [{"kind": "preference", "key": "style", "value": "concise"}]},
},
)
)
assert "cya" in system.lower()
assert "list files" in user
assert "/home/user/proj" in user
assert "hi" in user
assert "concise" in user