diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 3e79bdcd38a4..d98520356e01 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -585,46 +585,86 @@ class HermesACPAgent(acp.Agent): return f"{raw_provider}:{raw_model}" def _build_model_state(self, state: SessionState) -> SessionModelState | None: - """Return the ACP model selector payload for editors like Zed.""" + """Return authenticated providers and their models for ACP clients. + + The shared Hermes inventory is also used by ``hermes model``, the TUI, + and the dashboard. Keeping ACP on that substrate prevents its selector + from silently collapsing to the current provider's curated list. + """ model = str(state.model or getattr(state.agent, "model", "") or "").strip() provider = getattr(state.agent, "provider", None) or detect_provider() or "openrouter" try: - from hermes_cli.models import curated_models_for_provider, normalize_provider, provider_label + from hermes_cli.inventory import build_models_payload, load_picker_context + from hermes_cli.models import normalize_provider, provider_label normalized_provider = normalize_provider(provider) - provider_name = provider_label(normalized_provider) + context = load_picker_context().with_overrides( + current_provider=normalized_provider, + current_model=model, + current_base_url=str(getattr(state.agent, "base_url", "") or ""), + ) + payload = build_models_payload( + context, + explicit_only=True, + include_unconfigured=False, + picker_hints=False, + canonical_order=True, + pricing=False, + capabilities=False, + refresh=False, + probe_custom_providers=False, + probe_current_custom_provider=False, + max_models=None, + ) + available_models: list[ModelInfo] = [] seen_ids: set[str] = set() - - for model_id, description in curated_models_for_provider(normalized_provider): - rendered_model = str(model_id or "").strip() - if not rendered_model: + for row in payload.get("providers") or []: + row_provider = normalize_provider(str(row.get("slug") or "").strip()) + if not row_provider: continue - choice_id = self._encode_model_choice(normalized_provider, rendered_model) - if choice_id in seen_ids: - continue - desc_parts = [f"Provider: {provider_name}"] - if description: - desc_parts.append(str(description).strip()) - if rendered_model == model: - desc_parts.append("current") - available_models.append( - ModelInfo( - model_id=choice_id, - name=rendered_model, - description=" • ".join(part for part in desc_parts if part), - ) + provider_name = str(row.get("name") or "").strip() or provider_label( + row_provider ) - seen_ids.add(choice_id) + for model_entry in row.get("models") or []: + if isinstance(model_entry, dict): + rendered_model = str( + model_entry.get("id") + or model_entry.get("model") + or model_entry.get("name") + or "" + ).strip() + else: + rendered_model = str(model_entry or "").strip() + if not rendered_model: + continue + choice_id = self._encode_model_choice(row_provider, rendered_model) + if choice_id in seen_ids: + continue + is_current = ( + row_provider == normalized_provider and rendered_model == model + ) + description = f"Provider: {provider_name}" + if is_current: + description += " • current" + available_models.append( + ModelInfo( + model_id=choice_id, + name=f"{provider_name} · {rendered_model}", + description=description, + ) + ) + seen_ids.add(choice_id) current_model_id = self._encode_model_choice(normalized_provider, model) if current_model_id and current_model_id not in seen_ids: + provider_name = provider_label(normalized_provider) available_models.insert( 0, ModelInfo( model_id=current_model_id, - name=model, + name=f"{provider_name} · {model}", description=f"Provider: {provider_name} • current", ), ) diff --git a/tests/acp/test_server.py b/tests/acp/test_server.py index 1d9157f44fb0..015dbbfa1827 100644 --- a/tests/acp/test_server.py +++ b/tests/acp/test_server.py @@ -238,23 +238,116 @@ class TestSessionOps: assert state.cwd == "/home/user/project" @pytest.mark.asyncio - async def test_new_session_returns_model_state(self): + async def test_new_session_returns_authenticated_cross_provider_model_state(self): manager = SessionManager( - agent_factory=lambda: SimpleNamespace(model="gpt-5.4", provider="openai-codex") + agent_factory=lambda: SimpleNamespace( + model="gpt-5.4", + provider="openai-codex", + base_url="https://api.openai.com/v1", + ) ) acp_agent = HermesACPAgent(session_manager=manager) + picker_context = MagicMock() + picker_context.with_overrides.return_value = picker_context + payload = { + "providers": [ + { + "slug": "anthropic", + "name": "Anthropic", + "models": ["claude-sonnet-4-6", "claude-sonnet-4-6"], + }, + { + "slug": "openai-codex", + "name": "OpenAI Codex", + "models": [ + {"id": "gpt-5.4"}, + "gpt-5.4-mini", + ], + }, + ], + } - with patch( - "hermes_cli.models.curated_models_for_provider", - return_value=[("gpt-5.4", "recommended"), ("gpt-5.4-mini", "")], + with ( + patch("hermes_cli.inventory.load_picker_context", return_value=picker_context), + patch("hermes_cli.inventory.build_models_payload", return_value=payload) as build_payload, ): resp = await acp_agent.new_session(cwd="/tmp") assert isinstance(resp.models, SessionModelState) assert resp.models.current_model_id == "openai-codex:gpt-5.4" - assert resp.models.available_models[0].model_id == "openai-codex:gpt-5.4" - assert resp.models.available_models[0].description is not None - assert "Provider:" in resp.models.available_models[0].description + assert [model.model_id for model in resp.models.available_models] == [ + "anthropic:claude-sonnet-4-6", + "openai-codex:gpt-5.4", + "openai-codex:gpt-5.4-mini", + ] + assert [model.name for model in resp.models.available_models] == [ + "Anthropic · claude-sonnet-4-6", + "OpenAI Codex · gpt-5.4", + "OpenAI Codex · gpt-5.4-mini", + ] + assert resp.models.available_models[1].description is not None + assert "current" in resp.models.available_models[1].description + picker_context.with_overrides.assert_called_once_with( + current_provider="openai-codex", + current_model="gpt-5.4", + current_base_url="https://api.openai.com/v1", + ) + build_payload.assert_called_once_with( + picker_context, + explicit_only=True, + include_unconfigured=False, + picker_hints=False, + canonical_order=True, + pricing=False, + capabilities=False, + refresh=False, + probe_custom_providers=False, + probe_current_custom_provider=False, + max_models=None, + ) + + @pytest.mark.asyncio + async def test_new_session_keeps_current_model_missing_from_inventory(self): + manager = SessionManager( + agent_factory=lambda: SimpleNamespace( + model="claude-custom", + provider="anthropic", + base_url="https://api.anthropic.com", + ) + ) + acp_agent = HermesACPAgent(session_manager=manager) + picker_context = MagicMock() + picker_context.with_overrides.return_value = picker_context + payload = { + "providers": [ + { + "slug": "anthropic", + "name": "Anthropic", + "models": ["claude-sonnet-4-6"], + }, + { + "slug": "openai-codex", + "name": "OpenAI Codex", + "models": ["gpt-5.4"], + }, + ], + } + + with ( + patch("hermes_cli.inventory.load_picker_context", return_value=picker_context), + patch("hermes_cli.inventory.build_models_payload", return_value=payload), + ): + resp = await acp_agent.new_session(cwd="/tmp") + + assert resp.models is not None + assert resp.models.current_model_id == "anthropic:claude-custom" + assert [model.model_id for model in resp.models.available_models] == [ + "anthropic:claude-custom", + "anthropic:claude-sonnet-4-6", + "openai-codex:gpt-5.4", + ] + assert resp.models.available_models[0].name == "Anthropic · claude-custom" + assert resp.models.available_models[0].description == "Provider: Anthropic • current" @pytest.mark.asyncio async def test_available_commands_include_help(self, agent): @@ -1106,6 +1199,9 @@ class TestSessionConfiguration: with patch("run_agent.AIAgent", side_effect=fake_agent): acp_agent = HermesACPAgent(session_manager=manager) state = manager.create_session(cwd="/tmp") + assert state.agent.provider == "openrouter" + assert state.agent.base_url == "https://openrouter.example/v1" + assert state.agent.api_mode == "chat_completions" result = await acp_agent.set_session_model( model_id="anthropic:claude-sonnet-4-6", session_id=state.session_id, @@ -1115,8 +1211,58 @@ class TestSessionConfiguration: assert state.model == "claude-sonnet-4-6" assert state.agent.provider == "anthropic" assert state.agent.base_url == "https://anthropic.example/v1" + assert state.agent.api_mode == "anthropic_messages" assert runtime_calls[-1] == "anthropic" + @pytest.mark.asyncio + async def test_set_session_model_plain_choice_keeps_current_provider_runtime( + self, tmp_path, monkeypatch + ): + manager = SessionManager( + db=SessionDB(tmp_path / "state.db"), + agent_factory=lambda: SimpleNamespace( + model="old-model", + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + ), + ) + acp_agent = HermesACPAgent(session_manager=manager) + state = manager.create_session(cwd="/tmp") + replacement_agent = SimpleNamespace( + model="new-model", + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + ) + make_agent = MagicMock(return_value=replacement_agent) + monkeypatch.setattr(manager, "_make_agent", make_agent) + monkeypatch.setattr( + "hermes_cli.models.parse_model_input", + lambda raw, current: (current, raw), + ) + monkeypatch.setattr( + "hermes_cli.models.detect_provider_for_model", + lambda model, current: None, + ) + + result = await acp_agent.set_session_model( + model_id="new-model", + session_id=state.session_id, + ) + + assert isinstance(result, SetSessionModelResponse) + assert state.model == "new-model" + assert state.agent is replacement_agent + make_agent.assert_called_once_with( + session_id=state.session_id, + cwd="/tmp", + model="new-model", + requested_provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + ) + # --------------------------------------------------------------------------- # prompt