diff --git a/src/ucode/managed_wizard.py b/src/ucode/managed_wizard.py index 5f7be5d..dab1882 100644 --- a/src/ucode/managed_wizard.py +++ b/src/ucode/managed_wizard.py @@ -90,6 +90,12 @@ "codex": "managed_config.toml", } +# Shown whenever the workspace's coding-agent-config APIs return FEATURE_DISABLED. +CODING_AGENT_CONFIGS_DISABLED_MESSAGE = ( + "Workspace-managed coding agent configuration is not available on this workspace. Use " + "`ucode configure` to set up agents for individual users instead." +) + BUDGET_POLICY_BLURB = ( "As the workspace spends more of a budget, a tiered spend policy automatically switches " "everyone's default agent and model to a cheaper one — for example Claude Code / Opus normally, " @@ -1277,6 +1283,10 @@ def _handle_existing_config(workspace: str, token: str) -> tuple[bool, dict | No with spinner("Checking for an existing managed config..."): existing, reason = get_managed_config(workspace, token) if reason is not None: + if "feature_disabled" in reason.lower(): + # Authoring a draft that the workspace cannot publish only leads the admin through a + # dead-end wizard. Stop before model discovery and point them to per-user setup instead. + raise RuntimeError(CODING_AGENT_CONFIGS_DISABLED_MESSAGE) print_note(f"Could not check for an existing config: {reason}") return True, None if existing is None: @@ -1928,11 +1938,7 @@ def show_command() -> int: def _explain_publish_failure(reason: str) -> str: lowered = reason.lower() if "feature_disabled" in lowered: - return ( - "Managed coding-agent configs aren't enabled on this workspace yet. Ask your Databricks " - "contact to enable the `codingAgentConfigCrudEnabled` flag for it, then re-run " - "`ucode apply`." - ) + return CODING_AGENT_CONFIGS_DISABLED_MESSAGE if "permission_denied" in lowered or "http 403" in lowered: return ( "Publishing a managed config requires workspace admin. Your account can read the " @@ -2031,6 +2037,8 @@ def apply_command(*, yes: bool = False) -> int: with spinner("Checking for an existing managed config..."): existing, reason = get_managed_config(workspace, token) if reason is not None: + if "feature_disabled" in reason.lower(): + raise RuntimeError(CODING_AGENT_CONFIGS_DISABLED_MESSAGE) raise RuntimeError( f"Could not check whether {workspace} already has a managed config: {reason}. " "Refusing to publish without knowing, since that could overwrite a config silently." diff --git a/tests/test_managed_wizard.py b/tests/test_managed_wizard.py index 8cdd9a2..550a67d 100644 --- a/tests/test_managed_wizard.py +++ b/tests/test_managed_wizard.py @@ -247,6 +247,29 @@ def test_read_failure_continues_with_a_note(self): assert not select.called assert note.called + def test_feature_disabled_blocks_setup_with_an_actionable_error(self): + # When the coding-agent-config APIs aren't enabled, the read fails with a FEATURE_DISABLED + # 404. Stop before authoring a draft that can never be published. + reason = ( + 'HTTP 404 Not Found: {"error_code":"FEATURE_DISABLED",' + '"message":"Coding agent config APIs are not enabled for this workspace."}' + ) + with ( + patch.object(wizard, "get_managed_config", return_value=(None, reason)), + patch.object(wizard, "prompt_for_selection") as select, + patch.object(wizard, "print_note") as note, + pytest.raises(RuntimeError) as exc_info, + ): + wizard._handle_existing_config(WORKSPACE, "token") + assert not select.called + message = str(exc_info.value) + assert message == wizard.CODING_AGENT_CONFIGS_DISABLED_MESSAGE + assert "`ucode configure`" in message + # The raw 404 / JSON body must not leak into the message. + assert "404" not in message + assert "FEATURE_DISABLED" not in message + assert not note.called + def test_choosing_create_continues_authoring(self): with ( patch.object( @@ -2518,6 +2541,24 @@ def fake_create(*a, **k): ) assert created["called"] is False + def test_feature_disabled_read_uses_the_shared_blocking_message(self): + managed_config_mod.save_managed_state(WORKSPACE, self.MANIFEST) + created = {"called": False} + + def fake_create(*a, **k): + created["called"] = True + return {}, None + + reason = 'HTTP 404 Not Found: {"error_code":"FEATURE_DISABLED"}' + with pytest.raises(RuntimeError) as exc_info: + self._run( + get_managed_config=lambda *a, **k: (None, reason), + create_coding_agent_config=fake_create, + ) + assert str(exc_info.value) == wizard.CODING_AGENT_CONFIGS_DISABLED_MESSAGE + assert "FEATURE_DISABLED" not in str(exc_info.value) + assert created["called"] is False + def test_existing_config_without_a_resource_name_is_an_error(self): managed_config_mod.save_managed_state(WORKSPACE, self.MANIFEST) with pytest.raises(RuntimeError, match="resource name"): @@ -2527,11 +2568,12 @@ def test_existing_config_without_a_resource_name_is_an_error(self): class TestPublishFailureMessages: """The server's error codes, turned into something an admin can act on.""" - def test_feature_disabled_names_the_flag(self): + def test_feature_disabled_uses_the_shared_message(self): message = wizard._explain_publish_failure( 'HTTP 400 Bad Request: {"error_code":"FEATURE_DISABLED","message":"..."}' ) - assert "codingAgentConfigCrudEnabled" in message + assert message == wizard.CODING_AGENT_CONFIGS_DISABLED_MESSAGE + assert "`ucode configure`" in message def test_permission_denied_says_admin_is_required(self): message = wizard._explain_publish_failure(