Files
wehub-resource-sync 2cab53bc94
Test Vector Database Adaptors / Test MCP Vector DB Tools (push) Has been cancelled
Tests / Code Quality (Ruff & Mypy) (push) Has been cancelled
Tests / Fast Unit Tests (parallel) (macos-latest, 3.11) (push) Has been cancelled
Tests / Fast Unit Tests (parallel) (macos-latest, 3.12) (push) Has been cancelled
Tests / Tests (push) Has been cancelled
Docker Publish / Build and Push Docker Images (map[description:Skill Seekers CLI - Convert documentation to AI skills dockerfile:Dockerfile name:skill-seekers]) (push) Has been cancelled
Docker Publish / Build and Push Docker Images (map[description:Skill Seekers MCP Server - 25 tools for AI assistants dockerfile:Dockerfile.mcp name:skill-seekers-mcp]) (push) Has been cancelled
Docker Publish / Test Docker Images (push) Has been cancelled
Tests / Fast Unit Tests (parallel) (ubuntu-latest, 3.10) (push) Has been cancelled
Tests / Fast Unit Tests (parallel) (ubuntu-latest, 3.11) (push) Has been cancelled
Tests / Fast Unit Tests (parallel) (ubuntu-latest, 3.12) (push) Has been cancelled
Tests / Serial / Integration / E2E Tests (push) Has been cancelled
Tests / MCP Server Tests (push) Has been cancelled
Test Vector Database Adaptors / Test chroma Adaptor (push) Has been cancelled
Test Vector Database Adaptors / Test faiss Adaptor (push) Has been cancelled
Test Vector Database Adaptors / Test qdrant Adaptor (push) Has been cancelled
Test Vector Database Adaptors / Test weaviate Adaptor (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:46:28 +08:00

912 lines
36 KiB
Python

#!/usr/bin/env python3
"""Tests for the AgentClient unified AI client.
NOTE: Uses @patch.dict(os.environ, ..., clear=True) which clears all env vars
during individual tests. This is safe for sequential execution but means this
file should run in a dedicated worker (--dist=loadfile) under pytest-xdist.
"""
import os
import subprocess
from unittest.mock import MagicMock, patch
from skill_seekers.cli.agent_client import (
DEFAULT_ENHANCE_TIMEOUT,
DEFAULT_MODELS,
UNLIMITED_TIMEOUT,
AgentClient,
get_default_timeout,
normalize_agent_name,
)
class TestNormalizeAgentName:
"""Test normalize_agent_name() alias resolution."""
def test_claude_aliases(self):
assert normalize_agent_name("claude-code") == "claude"
assert normalize_agent_name("claude_code") == "claude"
assert normalize_agent_name("claude") == "claude"
def test_kimi_aliases(self):
assert normalize_agent_name("kimi") == "kimi"
assert normalize_agent_name("kimi-cli") == "kimi"
assert normalize_agent_name("kimi_code") == "kimi"
assert normalize_agent_name("kimi-code") == "kimi"
def test_codex_aliases(self):
assert normalize_agent_name("codex") == "codex"
assert normalize_agent_name("codex-cli") == "codex"
def test_copilot_aliases(self):
assert normalize_agent_name("copilot") == "copilot"
assert normalize_agent_name("copilot-cli") == "copilot"
def test_opencode_aliases(self):
assert normalize_agent_name("opencode") == "opencode"
assert normalize_agent_name("open-code") == "opencode"
assert normalize_agent_name("open_code") == "opencode"
def test_custom_passthrough(self):
assert normalize_agent_name("custom") == "custom"
def test_unknown_name_passthrough(self):
assert normalize_agent_name("some-unknown-agent") == "some-unknown-agent"
def test_empty_string_defaults_to_claude(self):
assert normalize_agent_name("") == "claude"
def test_none_defaults_to_claude(self):
# The docstring says "if not agent_name" which covers None too,
# but the type hint says str. If called with empty string, it returns "claude".
assert normalize_agent_name("") == "claude"
def test_case_insensitive(self):
assert normalize_agent_name("Claude-Code") == "claude"
assert normalize_agent_name("KIMI-CLI") == "kimi"
assert normalize_agent_name("Codex") == "codex"
def test_whitespace_stripped(self):
assert normalize_agent_name(" claude ") == "claude"
assert normalize_agent_name(" kimi-cli ") == "kimi"
class TestDetectApiKey:
"""Test AgentClient.detect_api_key() static method."""
@patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test123"}, clear=True)
def test_detects_anthropic_key(self):
key, provider = AgentClient.detect_api_key()
assert key == "sk-ant-test123"
assert provider == "anthropic"
@patch.dict(os.environ, {"MOONSHOT_API_KEY": "moonshot-key-abc"}, clear=True)
def test_detects_moonshot_key(self):
key, provider = AgentClient.detect_api_key()
assert key == "moonshot-key-abc"
assert provider == "moonshot"
@patch.dict(os.environ, {"GOOGLE_API_KEY": "AIzaSyTest123"}, clear=True)
def test_detects_google_key(self):
key, provider = AgentClient.detect_api_key()
assert key == "AIzaSyTest123"
assert provider == "google"
@patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test-openai"}, clear=True)
def test_detects_openai_key(self):
key, provider = AgentClient.detect_api_key()
assert key == "sk-test-openai"
assert provider == "openai"
@patch.dict(os.environ, {"ANTHROPIC_AUTH_TOKEN": "sk-ant-auth"}, clear=True)
def test_detects_anthropic_auth_token(self):
key, provider = AgentClient.detect_api_key()
assert key == "sk-ant-auth"
assert provider == "anthropic"
@patch.dict(os.environ, {}, clear=True)
def test_no_key_returns_none(self):
key, provider = AgentClient.detect_api_key()
assert key is None
assert provider is None
@patch.dict(os.environ, {"ANTHROPIC_API_KEY": " "}, clear=True)
def test_whitespace_only_key_returns_none(self):
key, provider = AgentClient.detect_api_key()
assert key is None
assert provider is None
@patch.dict(
os.environ,
{"ANTHROPIC_API_KEY": "first-key", "OPENAI_API_KEY": "second-key"},
clear=True,
)
def test_priority_order_anthropic_first(self):
"""API_KEY_MAP is iterated in order; ANTHROPIC_API_KEY comes first."""
key, provider = AgentClient.detect_api_key()
assert key == "first-key"
assert provider == "anthropic"
class TestAgentClientInit:
"""Test AgentClient.__init__() mode auto-detection."""
@patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test"}, clear=True)
@patch.object(AgentClient, "_init_api_client", return_value=MagicMock())
def test_auto_mode_with_api_key_sets_api(self, mock_init):
client = AgentClient(mode="auto")
assert client.mode == "api"
assert client.api_key == "sk-ant-test"
@patch.dict(os.environ, {}, clear=True)
def test_auto_mode_without_api_key_sets_local(self):
client = AgentClient(mode="auto")
assert client.mode == "local"
assert client.api_key is None
@patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test"}, clear=True)
def test_explicit_local_mode_overrides_api_key(self):
client = AgentClient(mode="local")
assert client.mode == "local"
@patch.dict(os.environ, {}, clear=True)
@patch.object(AgentClient, "_init_api_client", return_value=MagicMock())
def test_explicit_api_mode_with_provided_key(self, mock_init):
client = AgentClient(mode="api", api_key="sk-ant-explicit")
assert client.mode == "api"
assert client.api_key == "sk-ant-explicit"
@patch.dict(os.environ, {}, clear=True)
def test_default_agent_is_claude(self):
client = AgentClient(mode="local")
assert client.agent == "claude"
assert client.agent_display == "Claude Code"
@patch.dict(os.environ, {"SKILL_SEEKER_AGENT": "kimi"}, clear=True)
def test_env_agent_override(self):
client = AgentClient(mode="local")
assert client.agent == "kimi"
@patch.dict(os.environ, {"SKILL_SEEKER_AGENT": "kimi"}, clear=True)
def test_explicit_agent_overrides_env(self):
client = AgentClient(mode="local", agent="codex")
assert client.agent == "codex"
@patch.dict(os.environ, {}, clear=True)
@patch.object(AgentClient, "_init_api_client", return_value=MagicMock())
def test_explicit_api_key_detects_provider(self, mock_init):
client = AgentClient(mode="api", api_key="sk-ant-mykey")
assert client.provider == "anthropic"
@patch.dict(os.environ, {}, clear=True)
@patch.object(AgentClient, "_init_api_client", return_value=MagicMock())
def test_explicit_openai_key_detects_provider(self, mock_init):
client = AgentClient(mode="api", api_key="sk-openai-key")
assert client.provider == "openai"
class TestDetectProviderFromKey:
"""Test AgentClient._detect_provider_from_key() static method."""
def test_anthropic_prefix(self):
assert AgentClient._detect_provider_from_key("sk-ant-abc123") == "anthropic"
def test_openai_prefix(self):
assert AgentClient._detect_provider_from_key("sk-abc123") == "openai"
def test_google_prefix(self):
assert AgentClient._detect_provider_from_key("AIzaSyTest") == "google"
@patch.dict(os.environ, {"MOONSHOT_API_KEY": "sk-moonshot-key"}, clear=True)
def test_moonshot_via_env_match(self):
result = AgentClient._detect_provider_from_key("sk-moonshot-key")
assert result == "moonshot"
@patch.dict(os.environ, {}, clear=True)
def test_sk_prefix_without_moonshot_env_defaults_to_openai(self):
result = AgentClient._detect_provider_from_key("sk-some-key")
assert result == "openai"
@patch.dict(os.environ, {}, clear=True)
def test_unknown_prefix_defaults_to_anthropic(self):
result = AgentClient._detect_provider_from_key("unknown-prefix-key")
assert result == "anthropic"
@patch.dict(os.environ, {"GOOGLE_API_KEY": "custom-google-key"}, clear=True)
def test_env_var_match_for_unknown_prefix(self):
result = AgentClient._detect_provider_from_key("custom-google-key")
assert result == "google"
class TestGetDefaultTimeout:
"""Test get_default_timeout() function."""
@patch.dict(os.environ, {}, clear=True)
def test_default_without_env(self):
assert get_default_timeout() == DEFAULT_ENHANCE_TIMEOUT
@patch.dict(os.environ, {"SKILL_SEEKER_ENHANCE_TIMEOUT": "unlimited"}, clear=True)
def test_unlimited_string(self):
assert get_default_timeout() == UNLIMITED_TIMEOUT
@patch.dict(os.environ, {"SKILL_SEEKER_ENHANCE_TIMEOUT": "none"}, clear=True)
def test_none_string(self):
assert get_default_timeout() == UNLIMITED_TIMEOUT
@patch.dict(os.environ, {"SKILL_SEEKER_ENHANCE_TIMEOUT": "0"}, clear=True)
def test_zero_string(self):
assert get_default_timeout() == UNLIMITED_TIMEOUT
@patch.dict(os.environ, {"SKILL_SEEKER_ENHANCE_TIMEOUT": "600"}, clear=True)
def test_valid_int_string(self):
assert get_default_timeout() == 600
@patch.dict(os.environ, {"SKILL_SEEKER_ENHANCE_TIMEOUT": "-5"}, clear=True)
def test_negative_value_returns_unlimited(self):
assert get_default_timeout() == UNLIMITED_TIMEOUT
@patch.dict(os.environ, {"SKILL_SEEKER_ENHANCE_TIMEOUT": "not_a_number"}, clear=True)
def test_invalid_string_returns_default(self):
assert get_default_timeout() == DEFAULT_ENHANCE_TIMEOUT
@patch.dict(os.environ, {"SKILL_SEEKER_ENHANCE_TIMEOUT": " UNLIMITED "}, clear=True)
def test_unlimited_with_whitespace_and_case(self):
assert get_default_timeout() == UNLIMITED_TIMEOUT
@patch.dict(os.environ, {"SKILL_SEEKER_ENHANCE_TIMEOUT": ""}, clear=True)
def test_empty_env_returns_default(self):
assert get_default_timeout() == DEFAULT_ENHANCE_TIMEOUT
class TestGetModel:
"""Test AgentClient.get_model() static method."""
@patch.dict(os.environ, {}, clear=True)
def test_default_anthropic_model(self):
model = AgentClient.get_model("anthropic")
assert model == DEFAULT_MODELS["anthropic"]
@patch.dict(os.environ, {}, clear=True)
def test_default_openai_model(self):
model = AgentClient.get_model("openai")
assert model == DEFAULT_MODELS["openai"]
@patch.dict(os.environ, {}, clear=True)
def test_default_google_model(self):
model = AgentClient.get_model("google")
assert model == DEFAULT_MODELS["google"]
@patch.dict(os.environ, {}, clear=True)
def test_default_moonshot_model(self):
model = AgentClient.get_model("moonshot")
assert model == DEFAULT_MODELS["moonshot"]
@patch.dict(os.environ, {"SKILL_SEEKER_MODEL": "my-custom-model"}, clear=True)
def test_global_override(self):
model = AgentClient.get_model("anthropic")
assert model == "my-custom-model"
@patch.dict(os.environ, {"ANTHROPIC_MODEL": "claude-opus-4-20250514"}, clear=True)
def test_provider_specific_env_var(self):
model = AgentClient.get_model("anthropic")
assert model == "claude-opus-4-20250514"
@patch.dict(
os.environ,
{"SKILL_SEEKER_MODEL": "global-model", "ANTHROPIC_MODEL": "provider-model"},
clear=True,
)
def test_global_override_takes_precedence_over_provider(self):
model = AgentClient.get_model("anthropic")
assert model == "global-model"
@patch.dict(os.environ, {}, clear=True)
def test_unknown_provider_falls_back_to_anthropic_default(self):
model = AgentClient.get_model("unknown-provider")
assert model == "claude-sonnet-4-20250514"
@patch.dict(os.environ, {"OPENAI_MODEL": "gpt-5"}, clear=True)
def test_openai_model_env_var(self):
model = AgentClient.get_model("openai")
assert model == "gpt-5"
@patch.dict(os.environ, {"GOOGLE_MODEL": "gemini-ultra"}, clear=True)
def test_google_model_env_var(self):
model = AgentClient.get_model("google")
assert model == "gemini-ultra"
class TestParseKimiOutput:
"""Test AgentClient._parse_kimi_output() static method."""
def test_valid_textpart_output(self):
raw = (
"TurnBegin(turn_id=1)\n"
"StepBegin(step_id=1)\n"
"TextPart(type='text', text='Hello world')\n"
"ThinkPart(type='think', think='...')\n"
"TextPart(type='text', text='Second line')\n"
)
result = AgentClient._parse_kimi_output(raw)
assert result == "Hello world\nSecond line"
def test_single_textpart(self):
raw = "TextPart(type='text', text='Only one part')\n"
result = AgentClient._parse_kimi_output(raw)
assert result == "Only one part"
def test_no_textpart_falls_back_to_raw(self):
raw = "Some random output without TextPart markers"
result = AgentClient._parse_kimi_output(raw)
assert result == raw
def test_empty_string_returns_empty(self):
result = AgentClient._parse_kimi_output("")
assert result == ""
def test_thinkpart_only_falls_back(self):
raw = "ThinkPart(type='think', think='internal thinking')"
result = AgentClient._parse_kimi_output(raw)
assert result == raw
class TestIsAvailable:
"""Test AgentClient.is_available() method."""
@patch.dict(os.environ, {}, clear=True)
def test_api_mode_with_client_is_available(self):
client = AgentClient(mode="local")
# Force to api mode with a client
client.mode = "api"
client.client = MagicMock()
assert client.is_available() is True
@patch.dict(os.environ, {}, clear=True)
def test_api_mode_without_client_is_not_available(self):
client = AgentClient(mode="local")
client.mode = "api"
client.client = None
assert client.is_available() is False
@patch.dict(os.environ, {}, clear=True)
@patch("subprocess.run")
def test_local_mode_claude_available(self, mock_run):
mock_run.return_value = MagicMock(returncode=0)
client = AgentClient(mode="local", agent="claude")
assert client.is_available() is True
mock_run.assert_called_once()
@patch.dict(os.environ, {}, clear=True)
@patch("subprocess.run", side_effect=FileNotFoundError)
def test_local_mode_cli_not_found(self, mock_run):
client = AgentClient(mode="local", agent="claude")
assert client.is_available() is False
@patch.dict(os.environ, {}, clear=True)
@patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="claude", timeout=5))
def test_local_mode_timeout(self, mock_run):
client = AgentClient(mode="local", agent="claude")
assert client.is_available() is False
@patch.dict(os.environ, {}, clear=True)
def test_local_mode_unknown_agent_not_available(self):
client = AgentClient(mode="local")
client.agent = "nonexistent-agent"
assert client.is_available() is False
@patch.dict(os.environ, {}, clear=True)
@patch("subprocess.run")
def test_local_mode_nonzero_returncode(self, mock_run):
mock_run.return_value = MagicMock(returncode=1)
client = AgentClient(mode="local", agent="codex")
assert client.is_available() is False
class TestDetectDefaultTarget:
"""Test AgentClient.detect_default_target() static method."""
@patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test"}, clear=True)
def test_anthropic_maps_to_claude(self):
assert AgentClient.detect_default_target() == "claude"
@patch.dict(os.environ, {"MOONSHOT_API_KEY": "moon-key"}, clear=True)
def test_moonshot_maps_to_kimi(self):
assert AgentClient.detect_default_target() == "kimi"
@patch.dict(os.environ, {"GOOGLE_API_KEY": "AIzaTest"}, clear=True)
def test_google_maps_to_gemini(self):
assert AgentClient.detect_default_target() == "gemini"
@patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test"}, clear=True)
def test_openai_maps_to_openai(self):
assert AgentClient.detect_default_target() == "openai"
@patch.dict(os.environ, {}, clear=True)
def test_no_key_defaults_to_markdown(self):
assert AgentClient.detect_default_target() == "markdown"
class TestCallApiTruncation:
"""Regression for ENH-01: a max_tokens-truncated API response must NOT be
returned — callers overwrite SKILL.md / parse JSON with this text, so a
truncated body silently corrupts their output."""
@patch.dict(os.environ, {}, clear=True)
@patch.object(AgentClient, "_init_api_client", return_value=MagicMock())
def test_anthropic_truncated_returns_none(self, _mock_init):
client = AgentClient(mode="api", api_key="sk-ant-x")
client.provider = "anthropic"
msg = MagicMock()
msg.stop_reason = "max_tokens"
msg.content = [MagicMock(text="half a SKILL.md that was cut off")]
client.client.messages.create.return_value = msg
assert client._call_api("prompt", max_tokens=10) is None
@patch.dict(os.environ, {}, clear=True)
@patch.object(AgentClient, "_init_api_client", return_value=MagicMock())
def test_anthropic_complete_returns_text(self, _mock_init):
client = AgentClient(mode="api", api_key="sk-ant-x")
client.provider = "anthropic"
msg = MagicMock()
msg.stop_reason = "end_turn"
msg.content = [MagicMock(text="full content")]
client.client.messages.create.return_value = msg
assert client._call_api("prompt", max_tokens=4096) == "full content"
@patch.dict(os.environ, {}, clear=True)
@patch.object(AgentClient, "_init_api_client", return_value=MagicMock())
def test_openai_length_finish_returns_none(self, _mock_init):
client = AgentClient(mode="api", api_key="sk-openai-x")
client.provider = "openai"
choice = MagicMock()
choice.finish_reason = "length"
choice.message.content = "truncated"
resp = MagicMock()
resp.choices = [choice]
client.client.chat.completions.create.return_value = resp
assert client._call_api("prompt", max_tokens=10) is None
@patch.dict(os.environ, {}, clear=True)
@patch.object(AgentClient, "_init_api_client", return_value=MagicMock())
def test_openai_forwards_caller_timeout(self, _mock_init):
"""Regression for ENH-06: the OpenAI branch must forward the caller's
timeout. It alone still hardcoded timeout=120s, so large enhancement
prompts died at 2 minutes."""
client = AgentClient(mode="api", api_key="sk-openai-x")
client.provider = "openai"
choice = MagicMock()
choice.finish_reason = "stop"
choice.message.content = "ok"
resp = MagicMock()
resp.choices = [choice]
client.client.chat.completions.create.return_value = resp
client._call_api("prompt", max_tokens=4096, timeout=999)
_, kwargs = client.client.chat.completions.create.call_args
assert kwargs["timeout"] == 999
class TestCallTruncationRetry:
"""A truncated API response triggers exactly ONE retry with double the
budget; persistent truncation still returns None (the gate is kept), and
non-truncation failures don't retry."""
def _client(self):
with patch.object(AgentClient, "_init_api_client", return_value=MagicMock()):
client = AgentClient(mode="api", api_key="sk-ant-x")
client.provider = "anthropic"
return client
@staticmethod
def _response(stop_reason, text):
msg = MagicMock()
msg.stop_reason = stop_reason
msg.content = [MagicMock(text=text)]
return msg
@patch.dict(os.environ, {}, clear=True)
def test_truncation_retries_once_with_doubled_budget(self):
client = self._client()
client.client.messages.create.side_effect = [
self._response("max_tokens", "half a SKILL.md"),
self._response("end_turn", "full content"),
]
assert client.call("prompt", max_tokens=4096) == "full content"
calls = client.client.messages.create.call_args_list
assert len(calls) == 2
assert calls[0].kwargs["max_tokens"] == 4096
assert calls[1].kwargs["max_tokens"] == 8192
@patch.dict(os.environ, {}, clear=True)
def test_persistent_truncation_returns_none_after_single_retry(self):
client = self._client()
client.client.messages.create.return_value = self._response("max_tokens", "half")
assert client.call("prompt", max_tokens=4096) is None
assert client.client.messages.create.call_count == 2
@patch.dict(os.environ, {}, clear=True)
def test_non_truncation_failure_does_not_retry(self):
client = self._client()
client.client.messages.create.side_effect = RuntimeError("boom")
assert client.call("prompt", max_tokens=4096) is None
assert client.client.messages.create.call_count == 1
class TestSkillMdEnhancementBudget:
"""The SKILL.md rewrite call sites must request a 16K-token budget —
with 4096, any skill whose rewrite exceeds ~16 KB hit the truncation
gate (None) on every attempt and was permanently un-enhanceable."""
def test_enhance_skill_requests_16384(self, monkeypatch, tmp_path):
from skill_seekers.cli import enhance_skill
mock_client = MagicMock()
mock_client.client = MagicMock()
mock_client.call.return_value = "enhanced"
monkeypatch.setattr(enhance_skill, "AgentClient", MagicMock(return_value=mock_client))
enhancer = enhance_skill.SkillEnhancer(tmp_path, api_key="sk-ant-x")
references = {
"api.md": {
"source": "web",
"path": str(tmp_path / "references" / "api.md"),
"confidence": "high",
"size": 9,
"content": "# API doc",
}
}
assert enhancer.enhance_skill_md(references, None) == "enhanced"
assert mock_client.call.call_args.kwargs["max_tokens"] == 16384
def test_adaptor_enhance_requests_16384(self, monkeypatch, tmp_path):
from skill_seekers.cli import agent_client as agent_client_module
from skill_seekers.cli.adaptors.base import SkillAdaptor
class _Adaptor(SkillAdaptor):
PLATFORM = "test"
PLATFORM_NAME = "Test"
def format_skill_md(self, skill_dir, metadata):
return ""
def package(self, skill_dir, output_path, **kwargs):
return output_path
def upload(self, package_path, api_key, **kwargs):
return {}
def _build_enhancement_prompt(self, name, references, current):
return "prompt"
(tmp_path / "references").mkdir()
(tmp_path / "references" / "doc.md").write_text("# Doc")
mock_client = MagicMock()
mock_client.mode = "api"
mock_client.client = MagicMock()
mock_client.call.return_value = "enhanced"
monkeypatch.setattr(agent_client_module, "AgentClient", MagicMock(return_value=mock_client))
ok = _Adaptor()._enhance_skill_md_via_client(tmp_path, "sk-ant-x", provider="anthropic")
assert ok is True
assert mock_client.call.call_args.kwargs["max_tokens"] == 16384
class TestProviderOverride:
"""Regression for ENH-02: a Moonshot/Kimi sk- key must not be misrouted to
OpenAI when an explicit provider override is set."""
@patch.dict(os.environ, {"SKILL_SEEKER_PROVIDER": "moonshot"}, clear=True)
def test_forced_moonshot_for_sk_key(self):
assert AgentClient._detect_provider_from_key("sk-somekey") == "moonshot"
@patch.dict(os.environ, {"SKILL_SEEKER_PROVIDER": "kimi"}, clear=True)
def test_kimi_alias_maps_to_moonshot(self):
assert AgentClient._detect_provider_from_key("sk-somekey") == "moonshot"
@patch.dict(os.environ, {"SKILL_SEEKER_PROVIDER": "gemini"}, clear=True)
def test_target_alias_maps_to_provider(self):
"""Target names from API_PROVIDERS are accepted as override aliases."""
assert AgentClient._detect_provider_from_key("some-key") == "google"
@patch.dict(os.environ, {}, clear=True)
def test_sk_key_without_override_still_openai(self):
assert AgentClient._detect_provider_from_key("sk-somekey") == "openai"
class TestKimiOutputParsing:
"""Regression for ENH-04: multi-line / apostrophe-containing Kimi output."""
def test_multiline_text_part_preserved(self):
raw = "TurnBegin(x)\nTextPart(type='text', text='line1\nline2\nline3')\nTurnEnd(x)"
assert AgentClient._parse_kimi_output(raw) == "line1\nline2\nline3"
def test_apostrophe_in_text_not_truncated(self):
raw = "TextPart(type='text', text='don't stop')\nTurnEnd()"
assert AgentClient._parse_kimi_output(raw) == "don't stop"
class TestCallLocalStrayJson:
"""Regression for ENH-05: a stray .json in the agent cwd must NOT shadow the
real stdout response when no output_file was requested."""
@patch.dict(os.environ, {}, clear=True)
def test_stray_json_does_not_shadow_stdout(self):
from pathlib import Path
client = AgentClient(mode="local", agent="claude")
def fake_run(_cmd, **kwargs):
# Simulate the agent writing an incidental json into its cwd.
Path(kwargs["cwd"], "scratch.json").write_text('{"junk": true}')
result = MagicMock()
result.returncode = 0
result.stdout = "REAL STDOUT RESPONSE"
result.stderr = ""
return result
with patch("subprocess.run", side_effect=fake_run):
out = client.call("hello") # local mode, output_file=None
assert out == "REAL STDOUT RESPONSE"
class TestCallLocalPromptDelivery:
"""Regression: agents whose command template never consumes {prompt_file}
(bare ["opencode"], custom commands without the placeholder) must receive
the prompt on stdin — previously subprocess.run got input=None and the
prompt sat in an unreferenced temp file."""
@staticmethod
def _fake_run(captured):
def fake_run(cmd, **kwargs):
captured["cmd"] = cmd
captured["input"] = kwargs.get("input")
result = MagicMock()
result.returncode = 0
result.stdout = "ok"
result.stderr = ""
return result
return fake_run
@patch.dict(os.environ, {}, clear=True)
def test_opencode_receives_prompt_on_stdin(self):
client = AgentClient(mode="local", agent="opencode")
captured = {}
with patch("subprocess.run", side_effect=self._fake_run(captured)):
assert client.call("the prompt") == "ok"
assert captured["cmd"][0] == "opencode"
assert captured["input"] == "the prompt"
@patch.dict(os.environ, {"SKILL_SEEKER_AGENT_CMD": "mytool run"}, clear=True)
def test_custom_without_prompt_file_pipes_stdin(self):
client = AgentClient(mode="local", agent="custom")
captured = {}
with patch("subprocess.run", side_effect=self._fake_run(captured)):
assert client.call("the prompt") == "ok"
assert captured["cmd"] == ["mytool", "run"]
assert captured["input"] == "the prompt"
@patch.dict(
os.environ,
{"SKILL_SEEKER_AGENT_CMD": 'mytool --system "be brief" {prompt_file}'},
clear=True,
)
def test_custom_command_is_shlex_split_and_substituted(self):
"""Custom templates go through build_local_agent_command: shlex split
(no literal quote chars) and {prompt_file} substitution — the same
path LocalSkillEnhancer already used for the identical env value."""
client = AgentClient(mode="local", agent="custom")
captured = {}
with patch("subprocess.run", side_effect=self._fake_run(captured)):
assert client.call("the prompt") == "ok"
assert captured["cmd"][:3] == ["mytool", "--system", "be brief"]
# {prompt_file} consumed → the prompt travels via the file, not stdin
assert captured["cmd"][3].endswith("prompt.md")
assert captured["input"] is None
@patch.dict(os.environ, {}, clear=True)
def test_prompt_file_agent_does_not_pipe_stdin(self):
client = AgentClient(mode="local", agent="claude")
captured = {}
with patch("subprocess.run", side_effect=self._fake_run(captured)):
assert client.call("the prompt") == "ok"
assert captured["input"] is None
class TestParseKimiOutputUnknownRecords:
"""Unknown record types must not leak into the extracted text.
The boundary lookahead is a generic CamelCase-constructor match: kimi's
record list isn't exhaustive (e.g. ToolCallPart), and enumerating known
types swallowed unknown records' internals into the captured text.
"""
def test_toolcallpart_between_textparts(self):
raw = (
"TextPart(type='text', text='A')\n"
"ToolCallPart(type='tool', name='x')\n"
"TextPart(type='text', text='B')\n"
"TurnEnd()"
)
assert AgentClient._parse_kimi_output(raw) == "A\nB"
def test_unknown_record_at_end(self):
raw = "TextPart(type='text', text='only')\nSomeNewRecord(foo=1)"
assert AgentClient._parse_kimi_output(raw) == "only"
class TestParseKimiOutputInternalBoundaries:
"""Regression: the old single DOTALL regex truncated a TextPart at an
internal "')" before a Capitalized line (print('done') then Config(...)),
and garbled/swallowed trailing records when its lookahead failed at the
true boundary (a non-record line after the closing "')")."""
def test_internal_close_before_single_capital_identifier(self):
raw = "TextPart(type='text', text='# Example\nprint('done')\nConfig(path)\nmore text')"
assert (
AgentClient._parse_kimi_output(raw)
== "# Example\nprint('done')\nConfig(path)\nmore text"
)
def test_internal_close_with_record_at_end(self):
raw = (
"TextPart(type='text', text='# Example\nprint('done')\nConfig(path)\nmore text')\n"
"TurnEnd()"
)
assert (
AgentClient._parse_kimi_output(raw)
== "# Example\nprint('done')\nConfig(path)\nmore text"
)
def test_non_record_line_before_next_record_yields_exact_text(self):
raw = (
"TextPart(type='text', text='Hello')\n"
"Some deprecation note\n"
"ThinkPart(type='think', think='...')"
)
assert AgentClient._parse_kimi_output(raw) == "Hello"
def test_trailing_non_record_line_after_last_textpart(self):
raw = "TextPart(type='text', text='Hello')\ndone."
assert AgentClient._parse_kimi_output(raw) == "Hello"
def test_unclosed_textpart_falls_back_to_raw(self):
raw = "TextPart(type='text', text='never closed"
assert AgentClient._parse_kimi_output(raw) == raw
class TestProviderRegistry:
"""API_PROVIDERS is the single source for provider detection priority."""
def _clear_keys(self, monkeypatch):
for var in (
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"GOOGLE_API_KEY",
"OPENAI_API_KEY",
"MOONSHOT_API_KEY",
):
monkeypatch.delenv(var, raising=False)
def test_moonshot_only_detected(self, monkeypatch):
from skill_seekers.cli.agent_client import detect_api_target
self._clear_keys(monkeypatch)
monkeypatch.setenv("MOONSHOT_API_KEY", "sk-moon")
assert detect_api_target() == ("kimi", "sk-moon")
def test_anthropic_outranks_moonshot(self, monkeypatch):
from skill_seekers.cli.agent_client import detect_api_target
self._clear_keys(monkeypatch)
monkeypatch.setenv("MOONSHOT_API_KEY", "sk-moon")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-x")
assert detect_api_target() == ("claude", "sk-ant-x")
def test_no_keys_returns_none(self, monkeypatch):
from skill_seekers.cli.agent_client import detect_api_target, get_provider_api_keys
self._clear_keys(monkeypatch)
assert detect_api_target() is None
assert all(v is None for v in get_provider_api_keys().values())
def test_api_key_map_derived_from_registry(self):
from skill_seekers.cli.agent_client import API_KEY_MAP, API_PROVIDERS
for p in API_PROVIDERS:
for var in p["env_vars"]:
assert API_KEY_MAP[var] == p["provider"]
class TestAgentPresetsSingleSource:
"""agent_client.AGENT_PRESETS is the single source of truth; the copy in
enhance_skill_local (whose kimi preset had silently diverged) is gone."""
def test_enhance_skill_local_shares_the_same_dict(self):
from skill_seekers.cli import agent_client, enhance_skill_local
assert enhance_skill_local.AGENT_PRESETS is agent_client.AGENT_PRESETS
def test_presets_have_required_shared_fields(self):
from skill_seekers.cli.agent_client import AGENT_PRESETS
for name, preset in AGENT_PRESETS.items():
assert "display_name" in preset, name
assert "command" in preset and preset["command"], name
assert "supports_skip_permissions" in preset, name
# The fields whose absence caused the original divergence:
assert AGENT_PRESETS["kimi"]["parse_output"] == "kimi"
assert "{cwd}" in AGENT_PRESETS["kimi"]["command"]
class TestAgentClientOverrides:
"""provider/base_url/model overrides + system/temperature passthrough —
what the platform adaptors need to route their enhance() calls through
AgentClient instead of hand-rolled SDK clients."""
def _client(self, monkeypatch, **kwargs):
# Avoid real SDK init: patch _init_api_client and inject a fake client.
from unittest.mock import MagicMock
from skill_seekers.cli.agent_client import AgentClient
monkeypatch.setattr(AgentClient, "_init_api_client", lambda _self: MagicMock())
return AgentClient(mode="api", api_key="sk-whatever", **kwargs)
def test_provider_override_beats_key_prefix(self, monkeypatch):
client = self._client(monkeypatch, provider="openai")
assert client.provider == "openai" # "sk-whatever" would detect as openai anyway
client2 = self._client(monkeypatch, provider="anthropic")
assert client2.provider == "anthropic"
def test_model_override_used_in_api_call(self, monkeypatch):
client = self._client(monkeypatch, provider="anthropic", model="my-model")
msg = client.client.messages.create.return_value
msg.stop_reason = "end_turn"
msg.content = [type("B", (), {"text": "out"})()]
assert client.call("hi") == "out"
assert client.client.messages.create.call_args.kwargs["model"] == "my-model"
def test_system_and_temperature_anthropic(self, monkeypatch):
client = self._client(monkeypatch, provider="anthropic")
msg = client.client.messages.create.return_value
msg.stop_reason = "end_turn"
msg.content = [type("B", (), {"text": "out"})()]
client.call("hi", system="be terse", temperature=0.3)
kwargs = client.client.messages.create.call_args.kwargs
assert kwargs["system"] == "be terse"
assert kwargs["temperature"] == 0.3
def test_system_and_temperature_openai(self, monkeypatch):
client = self._client(monkeypatch, provider="openai")
resp = client.client.chat.completions.create.return_value
resp.choices = [
type(
"C", (), {"finish_reason": "stop", "message": type("M", (), {"content": "out"})()}
)()
]
client.call("hi", system="be terse", temperature=0.3)
kwargs = client.client.chat.completions.create.call_args.kwargs
assert kwargs["messages"][0] == {"role": "system", "content": "be terse"}
assert kwargs["messages"][1]["role"] == "user"
assert kwargs["temperature"] == 0.3
def test_base_url_reaches_openai_client(self, monkeypatch):
import sys
from unittest.mock import MagicMock
from skill_seekers.cli.agent_client import AgentClient
fake_openai = MagicMock()
monkeypatch.setitem(sys.modules, "openai", fake_openai)
AgentClient(
mode="api",
api_key="sk-x",
provider="openai",
base_url="https://api.example.com/v1",
)
assert fake_openai.OpenAI.call_args.kwargs["base_url"] == "https://api.example.com/v1"