diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index 575f6fd170..dc03a00f60 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -199,8 +199,11 @@ def _format_messages_as_prompt( "IMPORTANT: If you take an action with a tool, you MUST output tool calls using {...} blocks with JSON exactly in OpenAI function-call shape.", "If no tool is needed, answer normally.", ] - if model: - sections.append(f"Hermes requested model hint: {model}") + # Deliberately no "requested model" line in the prompt: the model is + # applied for real via ACP session/set_model, and when the backend can't + # honor it (org-policy-disabled id) a prompt-text mention makes the + # serving model FALSELY self-identify as the requested one. Identity + # must come from the backend, not from prompt suggestion. # Copilot has no tools of its own that would collide with Hermes', so it # forwards the whole toolset (no allowlist). @@ -585,12 +588,22 @@ class CopilotACPClient: # and never fail the whole turn over model selection. if requested_model and requested_model != "copilot-acp": try: - available = { - str(m.get("modelId") or "").strip() + advertised = [ + m for m in ( (session.get("models") or {}).get("availableModels") or [] ) if isinstance(m, dict) + ] + available = { + str(m.get("modelId") or "").strip() + for m in advertised + # Org-policy-disabled ids can still appear in the list; + # selecting one silently serves the default model, so + # treat them as not offered. + if str( + ((m.get("_meta") or {}).get("copilotEnablement")) or "" + ).strip().lower() != "disabled" } if not available or requested_model in available: _request( diff --git a/tests/agent/test_acp_openai_bridge.py b/tests/agent/test_acp_openai_bridge.py index d1c0402604..f687461818 100644 --- a/tests/agent/test_acp_openai_bridge.py +++ b/tests/agent/test_acp_openai_bridge.py @@ -200,7 +200,10 @@ def test_copilot_prompt_still_carries_the_contract_and_the_tools(): assert "{...}" in prompt assert '"name": "memory"' in prompt assert '"name": "read_file"' in prompt # copilot forwards everything - assert "Hermes requested model hint: gpt-5" in prompt + # No prompt-text model mention: the model is applied via ACP + # session/set_model, and a prompt hint makes a substituted backend + # falsely self-identify as the requested model. + assert "model hint" not in prompt assert "hi" in prompt diff --git a/tests/agent/test_copilot_acp_client.py b/tests/agent/test_copilot_acp_client.py index 2c5cdc5e03..dbca05e95c 100644 --- a/tests/agent/test_copilot_acp_client.py +++ b/tests/agent/test_copilot_acp_client.py @@ -356,6 +356,9 @@ def _run_prompt_with_scripted_wire(model, session_result): str(m.get("modelId") or "").strip() for m in ((session.get("models") or {}).get("availableModels") or []) if isinstance(m, dict) + and str( + ((m.get("_meta") or {}).get("copilotEnablement")) or "" + ).strip().lower() != "disabled" } if not available or requested_model in available: wire.request( @@ -405,6 +408,25 @@ def test_set_model_skipped_for_provider_virtual_slug(): assert all(m != "session/set_model" for m, _ in reqs) +def test_set_model_skipped_for_policy_disabled_model(): + # A policy-disabled id may still be advertised; selecting it silently + # serves the default model, so it must not be treated as offered. + session = { + "sessionId": "s1", + "models": { + "availableModels": [ + {"modelId": "claude-sonnet-5"}, + { + "modelId": "claude-fable-5", + "_meta": {"copilotEnablement": "disabled"}, + }, + ] + }, + } + reqs = _run_prompt_with_scripted_wire("claude-fable-5", session) + assert all(m != "session/set_model" for m, _ in reqs) + + def test_run_prompt_receives_picker_model(): # _create_chat_completion must forward `model` into _run_prompt — the # original wiring dropped it, reducing the selection to prompt text.