Activation reaches plugin discovery before the application dependencies exist. Give PM its own locked Python project and runtime so it can install or repair the application without importing that dependency tree. Keep PM outside the application workspace. A shared uv workspace resolves the application graph and cannot provide this isolation. Route mutations through an isolated worker and preserve transaction callbacks, cancellation, custom package registrations, and correlated receipts. Use the same runtime builder for source installs and packaged payloads. Keep offline wheelhouse support in that builder. Nix builds the independent PM lock as a separate derivation. Refuse lazy-disabled bootstrap before installing tools or dependencies. Move first-party YAML readers and writers to ruamel. Keep the application lock's transitive PyYAML requirements for third-party packages. Verification: - Focused canonical Python suite: 177 passed, 1 host-gated skip. - Electron backend probes: 12 passed. Electron typecheck passed. - Both uv locks, scoped lint, Bash syntax, and whitespace checks passed. - Cold activation, corrupt-app repair, offline staging, and relocation ran. - Built and exercised the Nix PM runtime and standalone YAML merge script. Six broader caller test files retain the same 24 failing test IDs as an archive of HEAD. The existing real-home guard blocks those tests before they can exercise the affected paths. No full-suite pass is claimed. Native Windows signing and full Bionic package execution remain unverified.
229 lines
9.0 KiB
Python
229 lines
9.0 KiB
Python
"""Regression tests for OpenCode /v1 stripping during /model switch.
|
|
|
|
When switching to an Anthropic-routed OpenCode model mid-session (e.g.
|
|
``/model minimax-m2.7`` on opencode-go, or ``/model claude-sonnet-4-6``
|
|
on opencode-zen), the resolved base_url must have its trailing ``/v1``
|
|
stripped before being handed to the Anthropic SDK.
|
|
|
|
Without the strip, the SDK prepends its own ``/v1/messages`` path and
|
|
requests hit ``https://opencode.ai/zen/go/v1/v1/messages`` — a double
|
|
``/v1`` that returns OpenCode's website 404 page with HTML body.
|
|
|
|
``hermes_cli.runtime_provider.resolve_runtime_provider`` already strips
|
|
``/v1`` at fresh agent init (PR #4918), but the ``/model`` mid-session
|
|
switch path in ``hermes_cli.model_switch.switch_model`` was missing the
|
|
same logic — these tests guard against that regression.
|
|
"""
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.model_switch import switch_model
|
|
|
|
|
|
_MOCK_VALIDATION = {
|
|
"accepted": True,
|
|
"persist": True,
|
|
"recognized": True,
|
|
"message": None,
|
|
}
|
|
|
|
|
|
def _run_opencode_switch(
|
|
raw_input: str,
|
|
current_provider: str,
|
|
current_model: str,
|
|
current_base_url: str,
|
|
explicit_provider: str = "",
|
|
runtime_base_url: str = "",
|
|
):
|
|
"""Run switch_model with OpenCode mocks and return the result.
|
|
|
|
runtime_base_url defaults to current_base_url; tests can override it
|
|
to simulate the credential resolver returning a base_url different
|
|
from the session's current one.
|
|
"""
|
|
effective_runtime_base = runtime_base_url or current_base_url
|
|
with (
|
|
patch("hermes_cli.model_switch.resolve_alias", return_value=None),
|
|
patch("hermes_cli.model_switch.list_provider_models", return_value=[]),
|
|
patch(
|
|
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
|
return_value={
|
|
"api_key": "sk-opencode-fake",
|
|
"base_url": effective_runtime_base,
|
|
"api_mode": "chat_completions",
|
|
},
|
|
),
|
|
patch(
|
|
"hermes_cli.models_validate.validate_requested_model",
|
|
return_value=_MOCK_VALIDATION,
|
|
),
|
|
patch("hermes_cli.model_switch.get_model_info", return_value=None),
|
|
patch("hermes_cli.model_switch.get_model_capabilities", return_value=None),
|
|
patch("hermes_cli.models.detect_provider_for_model", return_value=None),
|
|
):
|
|
return switch_model(
|
|
raw_input=raw_input,
|
|
current_provider=current_provider,
|
|
current_model=current_model,
|
|
current_base_url=current_base_url,
|
|
current_api_key="sk-opencode-fake",
|
|
explicit_provider=explicit_provider,
|
|
)
|
|
|
|
|
|
class TestOpenCodeGoV1Strip:
|
|
"""OpenCode Go: ``/model minimax-*`` must strip /v1."""
|
|
|
|
def test_switch_to_minimax_m27_strips_v1(self):
|
|
"""GLM-5 → MiniMax-M2.7: base_url loses trailing /v1."""
|
|
result = _run_opencode_switch(
|
|
raw_input="minimax-m2.7",
|
|
current_provider="opencode-go",
|
|
current_model="glm-5",
|
|
current_base_url="https://opencode.ai/zen/go/v1",
|
|
)
|
|
|
|
assert result.success, f"switch_model failed: {result.error_message}"
|
|
assert result.api_mode == "anthropic_messages"
|
|
assert result.base_url == "https://opencode.ai/zen/go", (
|
|
f"Expected /v1 stripped for anthropic_messages; got {result.base_url}"
|
|
)
|
|
|
|
|
|
class TestOpenCodeZenV1Strip:
|
|
"""OpenCode Zen: ``/model claude-*`` must strip /v1."""
|
|
|
|
|
|
def test_switch_to_gpt_uses_codex_responses_keeps_v1(self):
|
|
"""GPT on opencode-zen uses codex_responses api_mode — /v1 kept."""
|
|
result = _run_opencode_switch(
|
|
raw_input="gpt-5.4",
|
|
current_provider="opencode-zen",
|
|
current_model="claude-sonnet-4-6",
|
|
current_base_url="https://opencode.ai/zen",
|
|
runtime_base_url="https://opencode.ai/zen/v1",
|
|
)
|
|
|
|
assert result.success
|
|
assert result.api_mode == "codex_responses"
|
|
assert result.base_url == "https://opencode.ai/zen/v1"
|
|
|
|
|
|
class TestAgentSwitchModelDefenseInDepth:
|
|
"""run_agent.AIAgent.switch_model() also strips /v1 as defense-in-depth."""
|
|
|
|
def test_agent_switch_model_strips_v1_for_anthropic_messages(self):
|
|
"""Even if a caller hands in a /v1 URL, the agent strips it."""
|
|
from run_agent import AIAgent
|
|
|
|
# Build a bare agent instance without running __init__; we only want
|
|
# to exercise switch_model's base_url normalization logic.
|
|
agent = AIAgent.__new__(AIAgent)
|
|
agent.model = "glm-5"
|
|
agent.provider = "opencode-go"
|
|
agent.base_url = "https://opencode.ai/zen/go/v1"
|
|
agent.api_key = "sk-opencode-fake"
|
|
agent.api_mode = "chat_completions"
|
|
agent._client_kwargs = {}
|
|
|
|
# Intercept the expensive client rebuild — we only need to verify
|
|
# that base_url was normalized before it reached the Anthropic
|
|
# client factory.
|
|
captured = {}
|
|
|
|
def _fake_build_anthropic_client(api_key, base_url, **kwargs):
|
|
captured["api_key"] = api_key
|
|
captured["base_url"] = base_url
|
|
return object() # placeholder client — no real calls expected
|
|
|
|
# The downstream cache/plumbing touches a bunch of private state
|
|
# that wasn't initialized above; we don't want to rebuild the full
|
|
# runtime for this single assertion, so short-circuit after the
|
|
# strip by raising inside the stubbed factory.
|
|
class _Sentinel(Exception):
|
|
pass
|
|
|
|
def _raise_after_capture(api_key, base_url, **kwargs):
|
|
captured["api_key"] = api_key
|
|
captured["base_url"] = base_url
|
|
raise _Sentinel("strip verified")
|
|
|
|
with patch(
|
|
"agent.anthropic_adapter.build_anthropic_client",
|
|
side_effect=_raise_after_capture,
|
|
), patch("agent.anthropic_credentials.resolve_anthropic_token", return_value=""), patch(
|
|
"agent.anthropic_credentials._is_oauth_token", return_value=False
|
|
):
|
|
with pytest.raises(_Sentinel):
|
|
agent.switch_model(
|
|
new_model="minimax-m2.7",
|
|
new_provider="opencode-go",
|
|
api_key="sk-opencode-fake",
|
|
base_url="https://opencode.ai/zen/go/v1",
|
|
api_mode="anthropic_messages",
|
|
)
|
|
|
|
assert captured.get("base_url") == "https://opencode.ai/zen/go", (
|
|
f"agent.switch_model did not strip /v1; passed {captured.get('base_url')} "
|
|
"to build_anthropic_client"
|
|
)
|
|
|
|
|
|
class TestStaleConfigDefaultDoesNotWedgeResolver:
|
|
"""Regression for the real bug Quentin hit.
|
|
|
|
When ``model.default`` in config.yaml is an OpenCode Anthropic-routed model
|
|
(e.g. ``claude-sonnet-4-6`` on opencode-zen) and the user does ``/model
|
|
kimi-k2.6 --provider opencode-zen`` session-only, the resolver must derive
|
|
api_mode from the model being requested, not the persisted default. The
|
|
earlier bug computed api_mode from ``model_cfg.get("default")``, flipped it
|
|
to ``anthropic_messages`` based on the stale Claude default, and stripped
|
|
``/v1``. The chat_completions override in switch_model() fixed api_mode but
|
|
never re-added ``/v1``, so requests landed on ``https://opencode.ai/zen``
|
|
and got OpenCode's website 404 HTML page.
|
|
|
|
These tests use the REAL ``resolve_runtime_provider`` (not a mock) so a
|
|
regression in the target_model plumbing surfaces immediately.
|
|
"""
|
|
|
|
def test_kimi_switch_keeps_v1_despite_claude_config_default(self, tmp_path, monkeypatch):
|
|
import hermes_yaml as yaml
|
|
import importlib
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "test-key")
|
|
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
|
"model": {"provider": "opencode-zen", "default": "claude-sonnet-4-6"},
|
|
}))
|
|
|
|
# Re-import with the new HERMES_HOME so config cache is fresh.
|
|
import hermes_cli.config as _cfg_mod
|
|
importlib.reload(_cfg_mod)
|
|
import hermes_cli.runtime_provider as _rp_mod
|
|
importlib.reload(_rp_mod)
|
|
import hermes_cli.model_switch as _ms_mod
|
|
importlib.reload(_ms_mod)
|
|
|
|
result = _ms_mod.switch_model(
|
|
raw_input="kimi-k2.6",
|
|
current_provider="opencode-zen",
|
|
current_model="claude-sonnet-4-6",
|
|
current_base_url="https://opencode.ai/zen", # stripped from prior claude turn
|
|
current_api_key="test-key",
|
|
is_global=False,
|
|
explicit_provider="opencode-zen",
|
|
)
|
|
|
|
assert result.success, f"switch failed: {result.error_message}"
|
|
assert result.base_url == "https://opencode.ai/zen/v1", (
|
|
f"base_url wedged at {result.base_url!r} - stale Claude config.default "
|
|
"caused api_mode to be computed as anthropic_messages, stripping /v1, "
|
|
"and chat_completions override never re-added it."
|
|
)
|
|
assert result.api_mode == "chat_completions"
|
|
|
|
|