Merge PR #1879: feat: integrate GitHub Copilot providers
This commit is contained in:
commit
8422196e89
33 changed files with 2700 additions and 168 deletions
|
|
@ -248,6 +248,31 @@ class TestVisionClientFallback:
|
|||
assert client.__class__.__name__ == "AnthropicAuxiliaryClient"
|
||||
assert model == "claude-haiku-4-5-20251001"
|
||||
|
||||
def test_resolve_provider_client_copilot_uses_runtime_credentials(self, monkeypatch):
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
return_value={
|
||||
"provider": "copilot",
|
||||
"api_key": "gh-cli-token",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
"source": "gh auth token",
|
||||
},
|
||||
),
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai,
|
||||
):
|
||||
client, model = resolve_provider_client("copilot", model="gpt-5.4")
|
||||
|
||||
assert client is not None
|
||||
assert model == "gpt-5.4"
|
||||
call_kwargs = mock_openai.call_args.kwargs
|
||||
assert call_kwargs["api_key"] == "gh-cli-token"
|
||||
assert call_kwargs["base_url"] == "https://api.githubcopilot.com"
|
||||
assert call_kwargs["default_headers"]["Editor-Version"]
|
||||
|
||||
def test_vision_auto_uses_anthropic_when_no_higher_priority_backend(self, monkeypatch):
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key")
|
||||
with (
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch, MagicMock
|
|||
|
||||
import pytest
|
||||
|
||||
from cron.scheduler import _resolve_origin, _resolve_delivery_target, _deliver_result, run_job
|
||||
from cron.scheduler import _resolve_origin, _resolve_delivery_target, _deliver_result, run_job, SILENT_MARKER
|
||||
|
||||
|
||||
class TestResolveOrigin:
|
||||
|
|
@ -449,3 +449,97 @@ class TestRunJobSkillBacked:
|
|||
assert "Instructions for blogwatcher." in prompt_arg
|
||||
assert "Instructions for find-nearby." in prompt_arg
|
||||
assert "Combine the results." in prompt_arg
|
||||
|
||||
|
||||
class TestSilentDelivery:
|
||||
"""Verify that [SILENT] responses suppress delivery while still saving output."""
|
||||
|
||||
def _make_job(self):
|
||||
return {
|
||||
"id": "monitor-job",
|
||||
"name": "monitor",
|
||||
"deliver": "origin",
|
||||
"origin": {"platform": "telegram", "chat_id": "123"},
|
||||
}
|
||||
|
||||
def test_normal_response_delivers(self):
|
||||
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||
patch("cron.scheduler.run_job", return_value=(True, "# output", "Results here", None)), \
|
||||
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||
patch("cron.scheduler.mark_job_run"):
|
||||
from cron.scheduler import tick
|
||||
tick(verbose=False)
|
||||
deliver_mock.assert_called_once()
|
||||
|
||||
def test_silent_response_suppresses_delivery(self, caplog):
|
||||
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||
patch("cron.scheduler.run_job", return_value=(True, "# output", "[SILENT]", None)), \
|
||||
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||
patch("cron.scheduler.mark_job_run"):
|
||||
from cron.scheduler import tick
|
||||
with caplog.at_level(logging.INFO, logger="cron.scheduler"):
|
||||
tick(verbose=False)
|
||||
deliver_mock.assert_not_called()
|
||||
assert any(SILENT_MARKER in r.message for r in caplog.records)
|
||||
|
||||
def test_silent_with_note_suppresses_delivery(self):
|
||||
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||
patch("cron.scheduler.run_job", return_value=(True, "# output", "[SILENT] No changes detected", None)), \
|
||||
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||
patch("cron.scheduler.mark_job_run"):
|
||||
from cron.scheduler import tick
|
||||
tick(verbose=False)
|
||||
deliver_mock.assert_not_called()
|
||||
|
||||
def test_silent_is_case_insensitive(self):
|
||||
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||
patch("cron.scheduler.run_job", return_value=(True, "# output", "[silent] nothing new", None)), \
|
||||
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||
patch("cron.scheduler.mark_job_run"):
|
||||
from cron.scheduler import tick
|
||||
tick(verbose=False)
|
||||
deliver_mock.assert_not_called()
|
||||
|
||||
def test_failed_job_always_delivers(self):
|
||||
"""Failed jobs deliver regardless of [SILENT] in output."""
|
||||
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||
patch("cron.scheduler.run_job", return_value=(False, "# output", "", "some error")), \
|
||||
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||
patch("cron.scheduler.mark_job_run"):
|
||||
from cron.scheduler import tick
|
||||
tick(verbose=False)
|
||||
deliver_mock.assert_called_once()
|
||||
|
||||
def test_output_saved_even_when_delivery_suppressed(self):
|
||||
with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \
|
||||
patch("cron.scheduler.run_job", return_value=(True, "# full output", "[SILENT]", None)), \
|
||||
patch("cron.scheduler.save_job_output") as save_mock, \
|
||||
patch("cron.scheduler._deliver_result") as deliver_mock, \
|
||||
patch("cron.scheduler.mark_job_run"):
|
||||
save_mock.return_value = "/tmp/out.md"
|
||||
from cron.scheduler import tick
|
||||
tick(verbose=False)
|
||||
save_mock.assert_called_once_with("monitor-job", "# full output")
|
||||
deliver_mock.assert_not_called()
|
||||
|
||||
|
||||
class TestBuildJobPromptSilentHint:
|
||||
"""Verify _build_job_prompt always injects [SILENT] guidance."""
|
||||
|
||||
def test_hint_always_present(self):
|
||||
from cron.scheduler import _build_job_prompt
|
||||
job = {"prompt": "Check for updates"}
|
||||
result = _build_job_prompt(job)
|
||||
assert "[SILENT]" in result
|
||||
assert "Check for updates" in result
|
||||
|
||||
def test_hint_present_even_without_prompt(self):
|
||||
from cron.scheduler import _build_job_prompt
|
||||
job = {"prompt": ""}
|
||||
result = _build_job_prompt(job)
|
||||
assert "[SILENT]" in result
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.models import (
|
||||
copilot_model_api_mode,
|
||||
fetch_github_model_catalog,
|
||||
curated_models_for_provider,
|
||||
fetch_api_models,
|
||||
github_model_reasoning_efforts,
|
||||
normalize_copilot_model_id,
|
||||
normalize_provider,
|
||||
parse_model_input,
|
||||
probe_api_models,
|
||||
|
|
@ -116,6 +120,7 @@ class TestNormalizeProvider:
|
|||
assert normalize_provider("glm") == "zai"
|
||||
assert normalize_provider("kimi") == "kimi-coding"
|
||||
assert normalize_provider("moonshot") == "kimi-coding"
|
||||
assert normalize_provider("github-copilot") == "copilot"
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert normalize_provider("OpenRouter") == "openrouter"
|
||||
|
|
@ -125,6 +130,8 @@ class TestProviderLabel:
|
|||
def test_known_labels_and_auto(self):
|
||||
assert provider_label("anthropic") == "Anthropic"
|
||||
assert provider_label("kimi") == "Kimi / Moonshot"
|
||||
assert provider_label("copilot") == "GitHub Copilot"
|
||||
assert provider_label("copilot-acp") == "GitHub Copilot ACP"
|
||||
assert provider_label("auto") == "Auto"
|
||||
|
||||
def test_unknown_provider_preserves_original_name(self):
|
||||
|
|
@ -145,6 +152,24 @@ class TestProviderModelIds:
|
|||
def test_zai_returns_glm_models(self):
|
||||
assert "glm-5" in provider_model_ids("zai")
|
||||
|
||||
def test_copilot_prefers_live_catalog(self):
|
||||
with patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "gh-token"}), \
|
||||
patch("hermes_cli.models._fetch_github_models", return_value=["gpt-5.4", "claude-sonnet-4.6"]):
|
||||
assert provider_model_ids("copilot") == ["gpt-5.4", "claude-sonnet-4.6"]
|
||||
|
||||
def test_copilot_acp_reuses_copilot_catalog(self):
|
||||
with patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "gh-token"}), \
|
||||
patch("hermes_cli.models._fetch_github_models", return_value=["gpt-5.4", "claude-sonnet-4.6"]):
|
||||
assert provider_model_ids("copilot-acp") == ["gpt-5.4", "claude-sonnet-4.6"]
|
||||
|
||||
def test_copilot_acp_falls_back_to_copilot_defaults(self):
|
||||
with patch("hermes_cli.auth.resolve_api_key_provider_credentials", side_effect=Exception("no token")), \
|
||||
patch("hermes_cli.models._fetch_github_models", return_value=None):
|
||||
ids = provider_model_ids("copilot-acp")
|
||||
|
||||
assert "gpt-5.4" in ids
|
||||
assert "copilot-acp" not in ids
|
||||
|
||||
|
||||
# -- fetch_api_models --------------------------------------------------------
|
||||
|
||||
|
|
@ -183,6 +208,82 @@ class TestFetchApiModels:
|
|||
assert probe["resolved_base_url"] == "http://localhost:8000/v1"
|
||||
assert probe["used_fallback"] is True
|
||||
|
||||
def test_probe_api_models_uses_copilot_catalog(self):
|
||||
class _Resp:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b'{"data": [{"id": "gpt-5.4", "model_picker_enabled": true, "supported_endpoints": ["/responses"], "capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}}}, {"id": "claude-sonnet-4.6", "model_picker_enabled": true, "supported_endpoints": ["/chat/completions"], "capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}}}, {"id": "text-embedding-3-small", "model_picker_enabled": true, "capabilities": {"type": "embedding"}}]}'
|
||||
|
||||
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()) as mock_urlopen:
|
||||
probe = probe_api_models("gh-token", "https://api.githubcopilot.com")
|
||||
|
||||
assert mock_urlopen.call_args[0][0].full_url == "https://api.githubcopilot.com/models"
|
||||
assert probe["models"] == ["gpt-5.4", "claude-sonnet-4.6"]
|
||||
assert probe["resolved_base_url"] == "https://api.githubcopilot.com"
|
||||
assert probe["used_fallback"] is False
|
||||
|
||||
def test_fetch_github_model_catalog_filters_non_chat_models(self):
|
||||
class _Resp:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b'{"data": [{"id": "gpt-5.4", "model_picker_enabled": true, "supported_endpoints": ["/responses"], "capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}}}, {"id": "text-embedding-3-small", "model_picker_enabled": true, "capabilities": {"type": "embedding"}}]}'
|
||||
|
||||
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()):
|
||||
catalog = fetch_github_model_catalog("gh-token")
|
||||
|
||||
assert catalog is not None
|
||||
assert [item["id"] for item in catalog] == ["gpt-5.4"]
|
||||
|
||||
|
||||
class TestGithubReasoningEfforts:
|
||||
def test_gpt5_supports_minimal_to_high(self):
|
||||
catalog = [{
|
||||
"id": "gpt-5.4",
|
||||
"capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}},
|
||||
"supported_endpoints": ["/responses"],
|
||||
}]
|
||||
assert github_model_reasoning_efforts("gpt-5.4", catalog=catalog) == [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
]
|
||||
|
||||
def test_legacy_catalog_reasoning_still_supported(self):
|
||||
catalog = [{"id": "openai/o3", "capabilities": ["reasoning"]}]
|
||||
assert github_model_reasoning_efforts("openai/o3", catalog=catalog) == [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
]
|
||||
|
||||
def test_non_reasoning_model_returns_empty(self):
|
||||
catalog = [{"id": "gpt-4.1", "capabilities": {"type": "chat", "supports": {}}}]
|
||||
assert github_model_reasoning_efforts("gpt-4.1", catalog=catalog) == []
|
||||
|
||||
|
||||
class TestCopilotNormalization:
|
||||
def test_normalize_old_github_models_slug(self):
|
||||
catalog = [{"id": "gpt-4.1"}, {"id": "gpt-5.4"}]
|
||||
assert normalize_copilot_model_id("openai/gpt-4.1-mini", catalog=catalog) == "gpt-4.1"
|
||||
|
||||
def test_copilot_api_mode_prefers_responses(self):
|
||||
catalog = [{
|
||||
"id": "gpt-5.4",
|
||||
"supported_endpoints": ["/responses"],
|
||||
"capabilities": {"type": "chat"},
|
||||
}]
|
||||
assert copilot_model_api_mode("gpt-5.4", catalog=catalog) == "codex_responses"
|
||||
|
||||
|
||||
# -- validate — format checks -----------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ def _clear_provider_env(monkeypatch):
|
|||
"OPENAI_BASE_URL",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"GITHUB_TOKEN",
|
||||
"GH_TOKEN",
|
||||
"GLM_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
|
|
@ -231,6 +233,152 @@ def test_setup_keep_current_anthropic_can_configure_openai_vision_default(tmp_pa
|
|||
assert env.get("AUXILIARY_VISION_MODEL") == "gpt-4o-mini"
|
||||
|
||||
|
||||
def test_setup_copilot_uses_gh_auth_and_saves_provider(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_clear_provider_env(monkeypatch)
|
||||
|
||||
config = load_config()
|
||||
|
||||
def fake_prompt_choice(question, choices, default=0):
|
||||
if question == "Select your inference provider:":
|
||||
assert choices[14] == "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"
|
||||
return 14
|
||||
if question == "Select default model:":
|
||||
assert "gpt-4.1" in choices
|
||||
assert "gpt-5.4" in choices
|
||||
return choices.index("gpt-5.4")
|
||||
if question == "Select reasoning effort:":
|
||||
assert "low" in choices
|
||||
assert "high" in choices
|
||||
return choices.index("high")
|
||||
if question == "Configure vision:":
|
||||
return len(choices) - 1
|
||||
tts_idx = _maybe_keep_current_tts(question, choices)
|
||||
if tts_idx is not None:
|
||||
return tts_idx
|
||||
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
||||
|
||||
def fake_prompt(message, *args, **kwargs):
|
||||
raise AssertionError(f"Unexpected prompt call: {message}")
|
||||
|
||||
def fake_get_auth_status(provider_id):
|
||||
if provider_id == "copilot":
|
||||
return {"logged_in": True}
|
||||
return {"logged_in": False}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt", fake_prompt)
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False)
|
||||
monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
||||
monkeypatch.setattr("hermes_cli.auth.get_auth_status", fake_get_auth_status)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
lambda provider_id: {
|
||||
"provider": provider_id,
|
||||
"api_key": "gh-cli-token",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
"source": "gh auth token",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.fetch_github_model_catalog",
|
||||
lambda api_key: [
|
||||
{
|
||||
"id": "gpt-4.1",
|
||||
"capabilities": {"type": "chat", "supports": {}},
|
||||
"supported_endpoints": ["/chat/completions"],
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.4",
|
||||
"capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}},
|
||||
"supported_endpoints": ["/responses"],
|
||||
},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
|
||||
|
||||
setup_model_provider(config)
|
||||
save_config(config)
|
||||
|
||||
env = _read_env(tmp_path)
|
||||
reloaded = load_config()
|
||||
|
||||
assert env.get("GITHUB_TOKEN") is None
|
||||
assert reloaded["model"]["provider"] == "copilot"
|
||||
assert reloaded["model"]["base_url"] == "https://api.githubcopilot.com"
|
||||
assert reloaded["model"]["default"] == "gpt-5.4"
|
||||
assert reloaded["model"]["api_mode"] == "codex_responses"
|
||||
assert reloaded["agent"]["reasoning_effort"] == "high"
|
||||
|
||||
|
||||
def test_setup_copilot_acp_uses_model_picker_and_saves_provider(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_clear_provider_env(monkeypatch)
|
||||
|
||||
config = load_config()
|
||||
|
||||
def fake_prompt_choice(question, choices, default=0):
|
||||
if question == "Select your inference provider:":
|
||||
assert choices[15] == "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"
|
||||
return 15
|
||||
if question == "Select default model:":
|
||||
assert "gpt-4.1" in choices
|
||||
assert "gpt-5.4" in choices
|
||||
return choices.index("gpt-5.4")
|
||||
if question == "Configure vision:":
|
||||
return len(choices) - 1
|
||||
tts_idx = _maybe_keep_current_tts(question, choices)
|
||||
if tts_idx is not None:
|
||||
return tts_idx
|
||||
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
||||
|
||||
def fake_prompt(message, *args, **kwargs):
|
||||
raise AssertionError(f"Unexpected prompt call: {message}")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt", fake_prompt)
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False)
|
||||
monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
||||
monkeypatch.setattr("hermes_cli.auth.get_auth_status", lambda provider_id: {"logged_in": provider_id == "copilot-acp"})
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
lambda provider_id: {
|
||||
"provider": "copilot",
|
||||
"api_key": "gh-cli-token",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
"source": "gh auth token",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.fetch_github_model_catalog",
|
||||
lambda api_key: [
|
||||
{
|
||||
"id": "gpt-4.1",
|
||||
"capabilities": {"type": "chat", "supports": {}},
|
||||
"supported_endpoints": ["/chat/completions"],
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.4",
|
||||
"capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}},
|
||||
"supported_endpoints": ["/responses"],
|
||||
},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
|
||||
|
||||
setup_model_provider(config)
|
||||
save_config(config)
|
||||
|
||||
reloaded = load_config()
|
||||
|
||||
assert reloaded["model"]["provider"] == "copilot-acp"
|
||||
assert reloaded["model"]["base_url"] == "acp://copilot"
|
||||
assert reloaded["model"]["default"] == "gpt-5.4"
|
||||
assert reloaded["model"]["api_mode"] == "chat_completions"
|
||||
|
||||
|
||||
def test_setup_switch_custom_to_codex_clears_custom_endpoint_and_updates_config(tmp_path, monkeypatch):
|
||||
"""Switching from custom to Codex should clear custom endpoint overrides."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
|
|
|||
|
|
@ -18,9 +18,12 @@ from hermes_cli.auth import (
|
|||
resolve_provider,
|
||||
get_api_key_provider_status,
|
||||
resolve_api_key_provider_credentials,
|
||||
get_external_process_provider_status,
|
||||
resolve_external_process_provider_credentials,
|
||||
get_auth_status,
|
||||
AuthError,
|
||||
KIMI_CODE_BASE_URL,
|
||||
_try_gh_cli_token,
|
||||
_resolve_kimi_base_url,
|
||||
)
|
||||
|
||||
|
|
@ -33,6 +36,8 @@ class TestProviderRegistry:
|
|||
"""Test that new providers are correctly registered."""
|
||||
|
||||
@pytest.mark.parametrize("provider_id,name,auth_type", [
|
||||
("copilot-acp", "GitHub Copilot ACP", "external_process"),
|
||||
("copilot", "GitHub Copilot", "api_key"),
|
||||
("zai", "Z.AI / GLM", "api_key"),
|
||||
("kimi-coding", "Kimi / Moonshot", "api_key"),
|
||||
("minimax", "MiniMax", "api_key"),
|
||||
|
|
@ -52,6 +57,11 @@ class TestProviderRegistry:
|
|||
assert pconfig.api_key_env_vars == ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY")
|
||||
assert pconfig.base_url_env_var == "GLM_BASE_URL"
|
||||
|
||||
def test_copilot_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["copilot"]
|
||||
assert pconfig.api_key_env_vars == ("GITHUB_TOKEN", "GH_TOKEN")
|
||||
assert pconfig.base_url_env_var == ""
|
||||
|
||||
def test_kimi_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["kimi-coding"]
|
||||
assert pconfig.api_key_env_vars == ("KIMI_API_KEY",)
|
||||
|
|
@ -78,6 +88,8 @@ class TestProviderRegistry:
|
|||
assert pconfig.base_url_env_var == "KILOCODE_BASE_URL"
|
||||
|
||||
def test_base_urls(self):
|
||||
assert PROVIDER_REGISTRY["copilot"].inference_base_url == "https://api.githubcopilot.com"
|
||||
assert PROVIDER_REGISTRY["copilot-acp"].inference_base_url == "acp://copilot"
|
||||
assert PROVIDER_REGISTRY["zai"].inference_base_url == "https://api.z.ai/api/paas/v4"
|
||||
assert PROVIDER_REGISTRY["kimi-coding"].inference_base_url == "https://api.moonshot.ai/v1"
|
||||
assert PROVIDER_REGISTRY["minimax"].inference_base_url == "https://api.minimax.io/v1"
|
||||
|
|
@ -105,8 +117,9 @@ PROVIDER_ENV_VARS = (
|
|||
"AI_GATEWAY_API_KEY", "AI_GATEWAY_BASE_URL",
|
||||
"KILOCODE_API_KEY", "KILOCODE_BASE_URL",
|
||||
"DASHSCOPE_API_KEY", "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY",
|
||||
"NOUS_API_KEY",
|
||||
"OPENAI_BASE_URL",
|
||||
"NOUS_API_KEY", "GITHUB_TOKEN", "GH_TOKEN",
|
||||
"OPENAI_BASE_URL", "HERMES_COPILOT_ACP_COMMAND", "COPILOT_CLI_PATH",
|
||||
"HERMES_COPILOT_ACP_ARGS", "COPILOT_ACP_BASE_URL",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -176,6 +189,16 @@ class TestResolveProvider:
|
|||
assert resolve_provider("Z-AI") == "zai"
|
||||
assert resolve_provider("Kimi") == "kimi-coding"
|
||||
|
||||
def test_alias_github_copilot(self):
|
||||
assert resolve_provider("github-copilot") == "copilot"
|
||||
|
||||
def test_alias_github_models(self):
|
||||
assert resolve_provider("github-models") == "copilot"
|
||||
|
||||
def test_alias_github_copilot_acp(self):
|
||||
assert resolve_provider("github-copilot-acp") == "copilot-acp"
|
||||
assert resolve_provider("copilot-acp-agent") == "copilot-acp"
|
||||
|
||||
def test_unknown_provider_raises(self):
|
||||
with pytest.raises(AuthError):
|
||||
resolve_provider("nonexistent-provider-xyz")
|
||||
|
|
@ -218,6 +241,10 @@ class TestResolveProvider:
|
|||
monkeypatch.setenv("GLM_API_KEY", "glm-key")
|
||||
assert resolve_provider("auto") == "openrouter"
|
||||
|
||||
def test_auto_does_not_select_copilot_from_github_token(self, monkeypatch):
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "gh-test-token")
|
||||
assert resolve_provider("auto") == "openrouter"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Key Provider Status tests
|
||||
|
|
@ -251,12 +278,41 @@ class TestApiKeyProviderStatus:
|
|||
status = get_api_key_provider_status("kimi-coding")
|
||||
assert status["base_url"] == "https://custom.kimi.example/v1"
|
||||
|
||||
def test_copilot_status_uses_gh_cli_token(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.auth._try_gh_cli_token", lambda: "gh-cli-token")
|
||||
status = get_api_key_provider_status("copilot")
|
||||
assert status["configured"] is True
|
||||
assert status["logged_in"] is True
|
||||
assert status["key_source"] == "gh auth token"
|
||||
assert status["base_url"] == "https://api.githubcopilot.com"
|
||||
|
||||
def test_get_auth_status_dispatches_to_api_key(self, monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "mm-key")
|
||||
status = get_auth_status("minimax")
|
||||
assert status["configured"] is True
|
||||
assert status["provider"] == "minimax"
|
||||
|
||||
def test_copilot_acp_status_detects_local_cli(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_COPILOT_ACP_ARGS", "--acp --stdio --debug")
|
||||
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/usr/local/bin/{command}")
|
||||
|
||||
status = get_external_process_provider_status("copilot-acp")
|
||||
|
||||
assert status["configured"] is True
|
||||
assert status["logged_in"] is True
|
||||
assert status["command"] == "copilot"
|
||||
assert status["resolved_command"] == "/usr/local/bin/copilot"
|
||||
assert status["args"] == ["--acp", "--stdio", "--debug"]
|
||||
assert status["base_url"] == "acp://copilot"
|
||||
|
||||
def test_get_auth_status_dispatches_to_external_process(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/opt/bin/{command}")
|
||||
|
||||
status = get_auth_status("copilot-acp")
|
||||
|
||||
assert status["configured"] is True
|
||||
assert status["provider"] == "copilot-acp"
|
||||
|
||||
def test_non_api_key_provider(self):
|
||||
status = get_api_key_provider_status("nous")
|
||||
assert status["configured"] is False
|
||||
|
|
@ -276,6 +332,61 @@ class TestResolveApiKeyProviderCredentials:
|
|||
assert creds["base_url"] == "https://api.z.ai/api/paas/v4"
|
||||
assert creds["source"] == "GLM_API_KEY"
|
||||
|
||||
def test_resolve_copilot_with_github_token(self, monkeypatch):
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "gh-env-secret")
|
||||
creds = resolve_api_key_provider_credentials("copilot")
|
||||
assert creds["provider"] == "copilot"
|
||||
assert creds["api_key"] == "gh-env-secret"
|
||||
assert creds["base_url"] == "https://api.githubcopilot.com"
|
||||
assert creds["source"] == "GITHUB_TOKEN"
|
||||
|
||||
def test_resolve_copilot_with_gh_cli_fallback(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.auth._try_gh_cli_token", lambda: "gh-cli-secret")
|
||||
creds = resolve_api_key_provider_credentials("copilot")
|
||||
assert creds["provider"] == "copilot"
|
||||
assert creds["api_key"] == "gh-cli-secret"
|
||||
assert creds["base_url"] == "https://api.githubcopilot.com"
|
||||
assert creds["source"] == "gh auth token"
|
||||
|
||||
def test_try_gh_cli_token_uses_homebrew_path_when_not_on_path(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: None)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.os.path.isfile",
|
||||
lambda path: path == "/opt/homebrew/bin/gh",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.os.access",
|
||||
lambda path, mode: path == "/opt/homebrew/bin/gh" and mode == os.X_OK,
|
||||
)
|
||||
|
||||
calls = []
|
||||
|
||||
class _Result:
|
||||
returncode = 0
|
||||
stdout = "gh-cli-secret\n"
|
||||
|
||||
def _fake_run(cmd, capture_output, text, timeout):
|
||||
calls.append(cmd)
|
||||
return _Result()
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth.subprocess.run", _fake_run)
|
||||
|
||||
assert _try_gh_cli_token() == "gh-cli-secret"
|
||||
assert calls == [["/opt/homebrew/bin/gh", "auth", "token"]]
|
||||
|
||||
def test_resolve_copilot_acp_with_local_cli(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_COPILOT_ACP_ARGS", "--acp --stdio")
|
||||
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/usr/local/bin/{command}")
|
||||
|
||||
creds = resolve_external_process_provider_credentials("copilot-acp")
|
||||
|
||||
assert creds["provider"] == "copilot-acp"
|
||||
assert creds["api_key"] == "copilot-acp"
|
||||
assert creds["base_url"] == "acp://copilot"
|
||||
assert creds["command"] == "/usr/local/bin/copilot"
|
||||
assert creds["args"] == ["--acp", "--stdio"]
|
||||
assert creds["source"] == "process"
|
||||
|
||||
def test_resolve_kimi_with_key(self, monkeypatch):
|
||||
monkeypatch.setenv("KIMI_API_KEY", "kimi-secret-key")
|
||||
creds = resolve_api_key_provider_credentials("kimi-coding")
|
||||
|
|
@ -403,6 +514,53 @@ class TestRuntimeProviderResolution:
|
|||
assert result["provider"] == "kimi-coding"
|
||||
assert result["api_key"] == "auto-kimi-key"
|
||||
|
||||
def test_runtime_copilot_uses_gh_cli_token(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.auth._try_gh_cli_token", lambda: "gh-cli-secret")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
result = resolve_runtime_provider(requested="copilot")
|
||||
assert result["provider"] == "copilot"
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "gh-cli-secret"
|
||||
assert result["base_url"] == "https://api.githubcopilot.com"
|
||||
|
||||
def test_runtime_copilot_uses_responses_for_gpt_5_4(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.auth._try_gh_cli_token", lambda: "gh-cli-secret")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider._get_model_config",
|
||||
lambda: {"provider": "copilot", "default": "gpt-5.4"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.fetch_github_model_catalog",
|
||||
lambda api_key=None, timeout=5.0: [
|
||||
{
|
||||
"id": "gpt-5.4",
|
||||
"supported_endpoints": ["/responses"],
|
||||
"capabilities": {"type": "chat"},
|
||||
}
|
||||
],
|
||||
)
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
result = resolve_runtime_provider(requested="copilot")
|
||||
|
||||
assert result["provider"] == "copilot"
|
||||
assert result["api_mode"] == "codex_responses"
|
||||
|
||||
def test_runtime_copilot_acp_uses_process_runtime(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/usr/local/bin/{command}")
|
||||
monkeypatch.setenv("HERMES_COPILOT_ACP_ARGS", "--acp --stdio --debug")
|
||||
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
result = resolve_runtime_provider(requested="copilot-acp")
|
||||
|
||||
assert result["provider"] == "copilot-acp"
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "copilot-acp"
|
||||
assert result["base_url"] == "acp://copilot"
|
||||
assert result["command"] == "/usr/local/bin/copilot"
|
||||
assert result["args"] == ["--acp", "--stdio", "--debug"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _has_any_provider_configured tests
|
||||
|
|
@ -430,6 +588,16 @@ class TestHasAnyProviderConfigured:
|
|||
from hermes_cli.main import _has_any_provider_configured
|
||||
assert _has_any_provider_configured() is True
|
||||
|
||||
def test_gh_cli_token_counts(self, monkeypatch, tmp_path):
|
||||
from hermes_cli import config as config_module
|
||||
monkeypatch.setattr("hermes_cli.auth._try_gh_cli_token", lambda: "gh-cli-secret")
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||
from hermes_cli.main import _has_any_provider_configured
|
||||
assert _has_any_provider_configured() is True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Kimi Code auto-detection tests
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ def config_home(tmp_path, monkeypatch):
|
|||
monkeypatch.delenv("HERMES_MODEL", raising=False)
|
||||
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||
monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
|
|
@ -97,3 +99,114 @@ class TestProviderPersistsAfterModelSave:
|
|||
f"provider should be 'kimi-coding', got {model.get('provider')}"
|
||||
)
|
||||
assert model.get("default") == "kimi-k2.5"
|
||||
|
||||
def test_copilot_provider_saved_when_selected(self, config_home):
|
||||
"""_model_flow_copilot should persist provider/base_url/model together."""
|
||||
from hermes_cli.main import _model_flow_copilot
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
return_value={
|
||||
"provider": "copilot",
|
||||
"api_key": "gh-cli-token",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
"source": "gh auth token",
|
||||
},
|
||||
), patch(
|
||||
"hermes_cli.models.fetch_github_model_catalog",
|
||||
return_value=[
|
||||
{
|
||||
"id": "gpt-4.1",
|
||||
"capabilities": {"type": "chat", "supports": {}},
|
||||
"supported_endpoints": ["/chat/completions"],
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.4",
|
||||
"capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}},
|
||||
"supported_endpoints": ["/responses"],
|
||||
},
|
||||
],
|
||||
), patch(
|
||||
"hermes_cli.auth._prompt_model_selection",
|
||||
return_value="gpt-5.4",
|
||||
), patch(
|
||||
"hermes_cli.main._prompt_reasoning_effort_selection",
|
||||
return_value="high",
|
||||
), patch(
|
||||
"hermes_cli.auth.deactivate_provider",
|
||||
):
|
||||
_model_flow_copilot(load_config(), "old-model")
|
||||
|
||||
import yaml
|
||||
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict), f"model should be dict, got {type(model)}"
|
||||
assert model.get("provider") == "copilot"
|
||||
assert model.get("base_url") == "https://api.githubcopilot.com"
|
||||
assert model.get("default") == "gpt-5.4"
|
||||
assert model.get("api_mode") == "codex_responses"
|
||||
assert config["agent"]["reasoning_effort"] == "high"
|
||||
|
||||
def test_copilot_acp_provider_saved_when_selected(self, config_home):
|
||||
"""_model_flow_copilot_acp should persist provider/base_url/model together."""
|
||||
from hermes_cli.main import _model_flow_copilot_acp
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth.get_external_process_provider_status",
|
||||
return_value={
|
||||
"resolved_command": "/usr/local/bin/copilot",
|
||||
"command": "copilot",
|
||||
"base_url": "acp://copilot",
|
||||
},
|
||||
), patch(
|
||||
"hermes_cli.auth.resolve_external_process_provider_credentials",
|
||||
return_value={
|
||||
"provider": "copilot-acp",
|
||||
"api_key": "copilot-acp",
|
||||
"base_url": "acp://copilot",
|
||||
"command": "/usr/local/bin/copilot",
|
||||
"args": ["--acp", "--stdio"],
|
||||
"source": "process",
|
||||
},
|
||||
), patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
return_value={
|
||||
"provider": "copilot",
|
||||
"api_key": "gh-cli-token",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
"source": "gh auth token",
|
||||
},
|
||||
), patch(
|
||||
"hermes_cli.models.fetch_github_model_catalog",
|
||||
return_value=[
|
||||
{
|
||||
"id": "gpt-4.1",
|
||||
"capabilities": {"type": "chat", "supports": {}},
|
||||
"supported_endpoints": ["/chat/completions"],
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.4",
|
||||
"capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}},
|
||||
"supported_endpoints": ["/responses"],
|
||||
},
|
||||
],
|
||||
), patch(
|
||||
"hermes_cli.auth._prompt_model_selection",
|
||||
return_value="gpt-5.4",
|
||||
), patch(
|
||||
"hermes_cli.auth.deactivate_provider",
|
||||
):
|
||||
_model_flow_copilot_acp(load_config(), "old-model")
|
||||
|
||||
import yaml
|
||||
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict), f"model should be dict, got {type(model)}"
|
||||
assert model.get("provider") == "copilot-acp"
|
||||
assert model.get("base_url") == "acp://copilot"
|
||||
assert model.get("default") == "gpt-5.4"
|
||||
assert model.get("api_mode") == "chat_completions"
|
||||
|
|
|
|||
|
|
@ -631,6 +631,28 @@ class TestBuildApiKwargs:
|
|||
kwargs = agent._build_api_kwargs(messages)
|
||||
assert kwargs["extra_body"]["reasoning"]["effort"] == "medium"
|
||||
|
||||
def test_reasoning_sent_for_copilot_gpt5(self, agent):
|
||||
agent.base_url = "https://api.githubcopilot.com"
|
||||
agent.model = "gpt-5.4"
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
kwargs = agent._build_api_kwargs(messages)
|
||||
assert kwargs["extra_body"]["reasoning"] == {"effort": "medium"}
|
||||
|
||||
def test_reasoning_xhigh_normalized_for_copilot(self, agent):
|
||||
agent.base_url = "https://api.githubcopilot.com"
|
||||
agent.model = "gpt-5.4"
|
||||
agent.reasoning_config = {"enabled": True, "effort": "xhigh"}
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
kwargs = agent._build_api_kwargs(messages)
|
||||
assert kwargs["extra_body"]["reasoning"] == {"effort": "high"}
|
||||
|
||||
def test_reasoning_omitted_for_non_reasoning_copilot_model(self, agent):
|
||||
agent.base_url = "https://api.githubcopilot.com"
|
||||
agent.model = "gpt-4.1"
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
kwargs = agent._build_api_kwargs(messages)
|
||||
assert "reasoning" not in kwargs.get("extra_body", {})
|
||||
|
||||
def test_max_tokens_injected(self, agent):
|
||||
agent.max_tokens = 4096
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
|
|
@ -2172,6 +2194,41 @@ class TestFallbackAnthropicProvider:
|
|||
assert agent.client is mock_client
|
||||
|
||||
|
||||
def test_aiagent_uses_copilot_acp_client():
|
||||
with (
|
||||
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("run_agent.OpenAI") as mock_openai,
|
||||
patch("agent.copilot_acp_client.CopilotACPClient") as mock_acp_client,
|
||||
):
|
||||
acp_client = MagicMock()
|
||||
mock_acp_client.return_value = acp_client
|
||||
|
||||
agent = AIAgent(
|
||||
api_key="copilot-acp",
|
||||
base_url="acp://copilot",
|
||||
provider="copilot-acp",
|
||||
acp_command="/usr/local/bin/copilot",
|
||||
acp_args=["--acp", "--stdio"],
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
|
||||
assert agent.client is acp_client
|
||||
mock_openai.assert_not_called()
|
||||
mock_acp_client.assert_called_once()
|
||||
assert mock_acp_client.call_args.kwargs["base_url"] == "acp://copilot"
|
||||
assert mock_acp_client.call_args.kwargs["api_key"] == "copilot-acp"
|
||||
assert mock_acp_client.call_args.kwargs["command"] == "/usr/local/bin/copilot"
|
||||
assert mock_acp_client.call_args.kwargs["args"] == ["--acp", "--stdio"]
|
||||
|
||||
|
||||
def test_is_openai_client_closed_honors_custom_client_flag():
|
||||
assert AIAgent._is_openai_client_closed(SimpleNamespace(is_closed=True)) is True
|
||||
assert AIAgent._is_openai_client_closed(SimpleNamespace(is_closed=False)) is False
|
||||
|
||||
|
||||
class TestAnthropicBaseUrlPassthrough:
|
||||
"""Bug fix: base_url was filtered with 'anthropic in base_url', blocking proxies."""
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,27 @@ def _build_agent(monkeypatch):
|
|||
return agent
|
||||
|
||||
|
||||
def _build_copilot_agent(monkeypatch, *, model="gpt-5.4"):
|
||||
_patch_agent_bootstrap(monkeypatch)
|
||||
|
||||
agent = run_agent.AIAgent(
|
||||
model=model,
|
||||
provider="copilot",
|
||||
api_mode="codex_responses",
|
||||
base_url="https://api.githubcopilot.com",
|
||||
api_key="gh-token",
|
||||
quiet_mode=True,
|
||||
max_iterations=4,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
agent._cleanup_task_resources = lambda task_id: None
|
||||
agent._persist_session = lambda messages, history=None: None
|
||||
agent._save_trajectory = lambda messages, user_message, completed: None
|
||||
agent._save_session_log = lambda messages: None
|
||||
return agent
|
||||
|
||||
|
||||
def _codex_message_response(text: str):
|
||||
return SimpleNamespace(
|
||||
output=[
|
||||
|
|
@ -244,6 +265,28 @@ def test_build_api_kwargs_codex(monkeypatch):
|
|||
assert "extra_body" not in kwargs
|
||||
|
||||
|
||||
def test_build_api_kwargs_copilot_responses_omits_openai_only_fields(monkeypatch):
|
||||
agent = _build_copilot_agent(monkeypatch)
|
||||
kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}])
|
||||
|
||||
assert kwargs["model"] == "gpt-5.4"
|
||||
assert kwargs["store"] is False
|
||||
assert kwargs["tool_choice"] == "auto"
|
||||
assert kwargs["parallel_tool_calls"] is True
|
||||
assert kwargs["reasoning"] == {"effort": "medium"}
|
||||
assert "prompt_cache_key" not in kwargs
|
||||
assert "include" not in kwargs
|
||||
|
||||
|
||||
def test_build_api_kwargs_copilot_responses_omits_reasoning_for_non_reasoning_model(monkeypatch):
|
||||
agent = _build_copilot_agent(monkeypatch, model="gpt-4.1")
|
||||
kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}])
|
||||
|
||||
assert "reasoning" not in kwargs
|
||||
assert "include" not in kwargs
|
||||
assert "prompt_cache_key" not in kwargs
|
||||
|
||||
|
||||
def test_run_codex_stream_retries_when_completed_event_missing(monkeypatch):
|
||||
agent = _build_agent(monkeypatch)
|
||||
calls = {"stream": 0}
|
||||
|
|
|
|||
|
|
@ -441,6 +441,14 @@ class TestSearchLoopDetection(unittest.TestCase):
|
|||
self.assertNotIn("_warning", result)
|
||||
self.assertNotIn("error", result)
|
||||
|
||||
@patch("tools.file_tools._get_file_ops", return_value=_make_fake_file_ops())
|
||||
def test_pagination_offset_does_not_count_as_repeat(self, _mock_ops):
|
||||
"""Paginating truncated results should not be blocked as a repeat search."""
|
||||
for offset in (0, 50, 100, 150):
|
||||
result = json.loads(search_tool("def main", task_id="t1", offset=offset, limit=50))
|
||||
self.assertNotIn("_warning", result)
|
||||
self.assertNotIn("error", result)
|
||||
|
||||
@patch("tools.file_tools._get_file_ops", return_value=_make_fake_file_ops())
|
||||
def test_read_between_searches_resets_consecutive(self, _mock_ops):
|
||||
"""A read_file call between searches resets search consecutive counter."""
|
||||
|
|
|
|||
|
|
@ -154,6 +154,34 @@ class TestShouldAllowInstall:
|
|||
assert allowed is True
|
||||
assert "Force-installed" in reason
|
||||
|
||||
# -- agent-created policy --
|
||||
|
||||
def test_safe_agent_created_allowed(self):
|
||||
allowed, _ = should_allow_install(self._result("agent-created", "safe"))
|
||||
assert allowed is True
|
||||
|
||||
def test_caution_agent_created_allowed(self):
|
||||
"""Agent-created skills with caution verdict (e.g. docker refs) should pass."""
|
||||
f = [Finding("docker_pull", "medium", "supply_chain", "SKILL.md", 1, "docker pull img", "pulls Docker image")]
|
||||
allowed, reason = should_allow_install(self._result("agent-created", "caution", f))
|
||||
assert allowed is True
|
||||
assert "agent-created" in reason
|
||||
|
||||
def test_dangerous_agent_created_blocked(self):
|
||||
"""Agent-created skills with dangerous verdict (critical findings) stay blocked."""
|
||||
f = [Finding("env_exfil_curl", "critical", "exfiltration", "SKILL.md", 1, "curl $TOKEN", "exfiltration")]
|
||||
allowed, reason = should_allow_install(self._result("agent-created", "dangerous", f))
|
||||
assert allowed is False
|
||||
assert "Blocked" in reason
|
||||
|
||||
def test_force_overrides_dangerous_for_agent_created(self):
|
||||
f = [Finding("x", "critical", "c", "f", 1, "m", "d")]
|
||||
allowed, reason = should_allow_install(
|
||||
self._result("agent-created", "dangerous", f), force=True
|
||||
)
|
||||
assert allowed is True
|
||||
assert "Force-installed" in reason
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# scan_file — pattern detection
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue