diff --git a/README.md b/README.md index d64d972c..0f144eb7 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,11 @@ ug claude -r # resume last session ug codex --full-auto ``` +Use `--model-location .` on `ug claude` or `ug codex` for a launch-only schema +override. It does not change the saved model location or provider preference. An explicit +`--provider` similarly overrides a saved model location for that launch; the two explicit options +cannot be supplied together. + All agents route through Databricks AI Gateway using your workspace credentials — no API keys required. Codex uses the provider ID `Databricks` while keeping the `ucode` profile name. @@ -114,6 +119,19 @@ ug configure --agents claude,codex Available agent names are `codex`, `claude`, `gemini`, `opencode`, `copilot`, and `pi`. `cursor` is also accepted (MCP-only — it registers Databricks MCP servers but configures no models). +To discover models for Claude and Codex from one Unity Catalog schema, save a literal +`.` model location during configuration: + +```bash +ug configure --agents claude,codex --model-location main.models +``` + +The location is saved per selected agent within the workspace and reused by later `ug claude` and +`ug codex` launches. For each selected Claude/Codex agent, it replaces any saved Model Provider +Service choice. Reconfiguring that agent without `--model-location` clears its saved location; this +makes an explicit reconfigure the reset back to normal Hosted/provider selection without changing +the other agent or unrelated configure subcommands. + When naming several agents, configure sets up the available subset and reports the rest as skipped: ```bash @@ -389,15 +407,16 @@ The output looks like: | `ug revert` | Clear saved state and restore backed-up config files | | `ug configure --dry-run` | Preview config files without writing them | | `ug configure --agents claude,codex` | Configure specific agents without the interactive picker | +| `ug configure --agents claude,codex --model-location main.models` | Save a Unity Catalog model location for the selected Claude/Codex agents | | `ug configure --workspace https://first.databricks.com` | Configure a workspace without the interactive picker | | `ug configure --profile DEFAULT` | Configure using an existing Databricks CLI profile (host comes from `~/.databrickscfg`) | | `ug configure --profile DEFAULT --use-pat` | Authenticate with the profile's personal access token — no browser login | | `ug codex --enable-smart-routing` | Enable AI Gateway routing for Codex sessions and subagents | | `ug codex --refresh` | Re-check Databricks, refresh models/configuration, and launch Codex | -| `ug codex --model-location main.default` | Discover model services in the specified catalog and schema | +| `ug codex --model-location main.models` | Launch Codex with a temporary Unity Catalog model-location override | | `ug claude --enable-smart-routing` | Enable AI Gateway routing for Claude Code sessions and subagents | | `ug claude --refresh` | Re-check Databricks, refresh models/configuration, and launch Claude Code | -| `ug claude --model-location main.default` | Discover model services in the specified catalog and schema | +| `ug claude --model-location main.models` | Launch Claude Code with a temporary Unity Catalog model-location override | | `ug configure --agents claude,codex,pi` | Configure the requested agents that are available; skip the rest with a warning | | `ug configure --agents claude --mcp system.ai.slack` | Configure an agent and register its Databricks MCP server(s) in one command | | `ug mcp add --location system.ai` | Register a schema's MCP servers, keeping any already configured (additive; never removes) | diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index 40813892..0ed36e06 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -401,9 +401,9 @@ def configure_tool( state, model, provider=provider, parent_schema=parent_schema ) elif tool == "claude": - # A Model Provider Service routes by header and pins no Databricks - # model, so the usual "model required" guard doesn't apply to claude. - if not model and not provider: + # A Model Provider Service or model location routes by header and pins no global + # Databricks model, so the usual "model required" guard doesn't apply to claude. + if not model and not provider and not parent_schema: raise RuntimeError(f"A {tool} model must be selected before configuration.") result = claude.write_tool_config( state, diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index db46a8ac..2a6333be 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -452,7 +452,7 @@ def render_overlay( # provider and Claude Code's own canonical model names are sent verbatim — # pinning a Databricks model id here would mislabel the picker and isn't # routable. - elif claude_models and not provider: + elif claude_models and not provider and not parent_schema: # Picker rows show the raw routable id (e.g. "system.ai.claude-opus-4-8[1m]") # so users can see which gateway-routable model is behind each shortcut. # We deliberately don't set the `_NAME` companion env vars — the raw id diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 933df438..52b58965 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -84,6 +84,7 @@ managed_launch_model, managed_provider_family_models, managed_provider_service, + managed_static_models, managed_supplies_models, managed_unservable_models, recommended_agent, @@ -120,11 +121,15 @@ from ucode.state import ( STATE_PATH, clear_state, + developer_state_from_resolved, + get_model_location, get_provider_service, load_full_state, load_state, + load_workspace_state, save_state, set_current_workspace, + set_model_location, set_provider_service, ) from ucode.string_utils import is_valid_catalog_schema @@ -440,6 +445,7 @@ def configure_shared_state( databricks_ai_tools_enabled: bool | None = None, custom_oauth: CustomOAuthConfig | None = None, clear_custom_oauth: bool = False, + persist: bool = True, ) -> dict: """Log into Databricks, verify AI Gateway, fetch model lists, persist state. @@ -458,7 +464,9 @@ def configure_shared_state( ``ug configure``. The PAT/bearer is already exported (``apply_pat_environment`` in ``_launch_tool``) and the gateway was verified by that earlier configure. Only the local profile resolution and the shared state assembly still run; - the saved model lists are preserved. + the saved model lists are preserved. If ``persist`` is false, return the + assembled state without changing developer state; prompted first-run + launches use this to check managed policy before writing anything. """ workspace = normalize_workspace_url(workspace) prior_state = load_state() @@ -510,9 +518,10 @@ def configure_shared_state( profile = find_profile_name_for_host(workspace) if profile: state["profile"] = profile - save_state(state) + if persist: + save_state(state) # Scrub MCP entries ucode wrote for a previous workspace. - if previous_workspace and previous_workspace != workspace: + if persist and previous_workspace and previous_workspace != workspace: purge_cross_workspace_mcp_residue(state, workspace) # Diagnostic reasons are transient (attached after save_state so they # don't land on disk). No discovery ran, so there is nothing to report. @@ -635,10 +644,11 @@ def configure_shared_state( state["oss_models"] = oss_models if fetch_all or "opencode" in tools: state["opencode_models"] = opencode_models - save_state(state) + if persist: + save_state(state) # Scrub MCP entries that ucode wrote for the previous workspace so the new # workspace's agent configs aren't stale. - if previous_workspace and previous_workspace != workspace: + if persist and previous_workspace and previous_workspace != workspace: purge_cross_workspace_mcp_residue(state, workspace) # Diagnostic reasons are transient — attach after save_state so they don't # land on disk but are available to the caller for this run. @@ -757,6 +767,7 @@ def configure_workspace_command( databricks_ai_tools_enabled: bool | None = None, custom_oauth: CustomOAuthConfig | None = None, offer_optional_setup: bool = False, + model_location: str | None = None, ) -> int: if tool is not None and selected_tools is not None: raise RuntimeError("Use either --agent or --agents, not both.") @@ -764,9 +775,16 @@ def configure_workspace_command( # The Databricks-vs-Model-Provider-Service picker is shown only on the fully # interactive path (`ug configure` with no --agent/--agents). Naming agents # explicitly signals the non-interactive flow, which stays on Databricks. - offer_provider = tool is None and selected_tools is None + offer_provider = tool is None and selected_tools is None and model_location is None workspace_entries = workspaces or [_prompt_for_configuration(tool)] + requested_tools = [tool] if tool is not None else selected_tools + if model_location is not None: + cached_managed = load_managed_state(normalize_workspace_url(workspace_entries[0][0])) + _reject_configure_model_location( + cached_managed, + requested_tools or managed_enabled_tools(cached_managed or {}), + ) if tool is not None: states = _configure_shared_workspace_states( @@ -779,7 +797,19 @@ def configure_workspace_command( clear_custom_oauth=custom_oauth is None, ) state = states[0] - state = configure_single_tool(tool, state) + managed = None + if model_location is not None: + managed, _ = refresh_managed_config(state) + _reject_configure_model_location(managed, [tool]) + if model_location is not None and tool in CAN_USE_CACHED_CONFIG_AGENTS: + if managed is not None: + state = resolve_state(managed, state, tool) + state = _configure_tools_with_model_location( + state, [tool], model_location, install_ai_tools=False + ) + else: + candidate = _state_with_model_location(state, tool, model_location) + state = configure_single_tool(tool, candidate) install_databricks_ai_tools_for_agents([tool], state) spec = TOOL_SPECS[tool] console.print( @@ -804,27 +834,48 @@ def configure_workspace_command( clear_custom_oauth=custom_oauth is None, ) state = states[0] - save_state(state) # A published managed config means the admin dictates the setup: apply it to every enabled agent # now rather than prompting the developer to pick. managed, _ = refresh_managed_config(state) + if model_location is not None: + _reject_configure_model_location( + managed, + selected_tools or managed_enabled_tools(managed or {}), + ) if managed is not None: _announce_managed_config(managed) - for tool_name in managed_enabled_tools(managed): - if check_gateway_endpoint(state, tool_name): - configured = configure_selected_tools( - resolve_state(managed, state, tool_name), + developer_state = state + managed_tools = managed_enabled_tools(managed) + location_targets = selected_tools if selected_tools is not None else managed_tools + fallback_location_tools = [ + tool_name + for tool_name in location_targets + if model_location is not None + and tool_name in CAN_USE_CACHED_CONFIG_AGENTS + and not _managed_controls_model_source(managed, tool_name) + ] + tools_to_configure = managed_tools + [ + tool_name for tool_name in fallback_location_tools if tool_name not in managed_tools + ] + for tool_name in tools_to_configure: + resolved = resolve_state(managed, developer_state, tool_name) + if tool_name in fallback_location_tools: + configured = _configure_tools_with_model_location( + resolved, [tool_name], + model_location, install_ai_tools=not is_dry_run(), ) - # Each iteration resolves from `state` and persists a copy, so carry the - # accumulated available_tools forward — otherwise the last agent's save drops - # the earlier ones, and the MCP reconcile below only sees that final agent. - state["available_tools"] = configured.get("available_tools") or state.get( - "available_tools" + elif check_gateway_endpoint(developer_state, tool_name): + configured = configure_selected_tools( + resolved, [tool_name], install_ai_tools=not is_dry_run() ) - _print_configured_files(tool_name, configured) + else: + continue + _print_configured_files(tool_name, configured) + developer_state = developer_state_from_resolved(configured) + state = developer_state if not is_dry_run(): _configure_managed_mcp_servers(managed) _summarize_managed_config(managed, state["workspace"]) @@ -834,7 +885,10 @@ def configure_workspace_command( tools_to_check = selected_tools or list(TOOL_SPECS) for tool_name in tools_to_check: with spinner(f"Checking {TOOL_SPECS[tool_name]['display']} availability..."): - if check_gateway_endpoint(state, tool_name): + location_backed = ( + model_location is not None and tool_name in CAN_USE_CACHED_CONFIG_AGENTS + ) + if location_backed or check_gateway_endpoint(state, tool_name): available_on_workspace.append(tool_name) if not available_on_workspace: @@ -871,10 +925,12 @@ def configure_workspace_command( for tool_name in picked: state = _maybe_select_provider_service(tool_name, state) - if offer_optional_setup: - state = configure_selected_tools(state, picked, install_ai_tools=False) - else: - state = configure_selected_tools(state, picked) + state = _configure_tools_with_model_location( + state, + picked, + model_location, + install_ai_tools=not offer_optional_setup, + ) # This workspace has no managed config, so unregister any MCP servers a prior managed # workspace registered — otherwise switching workspaces leaves the old registry behind. @@ -901,6 +957,62 @@ def configure_workspace_command( return 0 +def _state_with_model_location(state: dict, tool: str, location: str | None) -> dict: + """Copy ``state`` and apply one agent's location/provider preference to the copy.""" + candidate = dict(state) + if tool in CAN_USE_CACHED_CONFIG_AGENTS: + set_model_location(candidate, tool, location) + if location is not None: + set_provider_service(candidate, tool, None) + return candidate + + +def _configure_model_location(state: dict, tools: list[str], location: str | None) -> dict: + """Rewrite selected Claude/Codex configs with the persisted model-location scope.""" + if location is None: + return state + for tool in tools: + state = configure_tool(tool, state, parent_schema=location) + return state + + +def _configure_tools_with_model_location( + state: dict, + tools: list[str], + location: str | None, + *, + install_ai_tools: bool, +) -> dict: + """Configure selected tools, using ``location`` as Claude/Codex's model source.""" + if location is None: + has_location_to_reset = any(get_model_location(state, tool) for tool in tools) + if has_location_to_reset: + configured = state + for tool in tools: + candidate = _state_with_model_location(configured, tool, None) + configured = configure_selected_tools(candidate, [tool], install_ai_tools=False) + if install_ai_tools: + install_databricks_ai_tools_for_agents(tools, configured) + return configured + if install_ai_tools: + return configure_selected_tools(state, tools) + return configure_selected_tools(state, tools, install_ai_tools=False) + + scoped_tools = [tool for tool in tools if tool in CAN_USE_CACHED_CONFIG_AGENTS] + regular_tools = [tool for tool in tools if tool not in scoped_tools] + if regular_tools: + state = configure_selected_tools(state, regular_tools, install_ai_tools=False) + for tool in scoped_tools: + candidate = _state_with_model_location(state, tool, location) + state = _configure_model_location(candidate, [tool], location) + existing = state.get("available_tools") or [] + state["available_tools"] = sorted(set(existing) | {tool}) + save_state(state) + if install_ai_tools: + install_databricks_ai_tools_for_agents(tools, state) + return state + + def status() -> int: state = load_state() workspace = state.get("workspace") @@ -938,6 +1050,9 @@ def status() -> int: provider_service = get_provider_service(state, tool) if configured and provider_service: print_kv("Model Provider Service", provider_service) + model_location = get_model_location(state, tool) + if configured and model_location: + print_kv("Model location", model_location) print_kv("Base URL", base_url) if configured and tool in MCP_CLIENTS: tool_mcp_servers = [ @@ -1811,21 +1926,55 @@ def claude_router_hook_cmd( sys.stdout.write(json.dumps(output)) -def _auto_configure_tool(tool: str, custom_oauth: CustomOAuthConfig | None = None) -> None: +def _auto_configure_tool( + tool: str, + custom_oauth: CustomOAuthConfig | None = None, + model_location: str | None = None, + explicit_provider: str | None = None, +) -> tuple[dict | None, bool]: """Configure a tool for launch without sending a separate validation prompt. The real agent session follows immediately; explicit configure retains the - test-prompt validation. + test-prompt validation. A prompted first run returns the managed-policy + snapshot fetched before agent/state writes so the caller reuses that exact + result for the rest of the launch. """ existing = load_state() workspace = existing.get("workspace") profile = existing.get("profile") + prompted_first_run = not workspace if not workspace: workspace, profile = _prompt_for_configuration(tool) configure_kwargs = {"custom_oauth": custom_oauth} if custom_oauth is not None else {} + if model_location is not None: + configure_kwargs["skip_model_discovery"] = True + if prompted_first_run: + configure_kwargs["persist"] = False state = configure_shared_state(workspace, profile=profile, tools=[tool], **configure_kwargs) - state = configure_single_tool(tool, state) + managed = None + coding_agent_config_feature_disabled = False + if prompted_first_run: + managed, coding_agent_config_feature_disabled = refresh_managed_config(state) + _reject_disabled_agent(managed, tool) + _reject_managed_source_override( + managed, + tool, + explicit_provider=explicit_provider, + explicit_model_location=model_location is not None, + ) + + if model_location is not None and tool in CAN_USE_CACHED_CONFIG_AGENTS: + # This is a launch-scoped choice, not an explicit `ug configure` preference. + # Write the agent config needed by the imminent session and remember only + # that the agent is available; a later bare launch must not inherit this + # one-shot location. + state = configure_tool(tool, state, parent_schema=model_location) + existing_tools = state.get("available_tools") or [] + state["available_tools"] = sorted(set(existing_tools) | {tool}) + save_state(state) + else: + state = configure_single_tool(tool, state) spec = TOOL_SPECS[tool] console.print( @@ -1838,6 +1987,7 @@ def _auto_configure_tool(tool: str, custom_oauth: CustomOAuthConfig | None = Non expand=False, ) ) + return managed, coding_agent_config_feature_disabled CAN_USE_CACHED_CONFIG_AGENTS = frozenset({"claude", "codex"}) @@ -1875,6 +2025,25 @@ def _disable_smart_routing_for_subcommand(tool: str, ctx: Any) -> Iterator[None] smart_routing_v2.restore_smart_routing_env(previous) +@contextmanager +def _claude_native_model_discovery_environment(enabled: bool) -> Iterator[None]: + """Enable Claude's gateway model picker only for the active launch.""" + if not enabled: + yield + return + + key = claude_agent.GATEWAY_MODEL_DISCOVERY_ENV_VAR + previous = os.environ.get(key) + os.environ[key] = "1" + try: + yield + finally: + if previous is None: + os.environ.pop(key, None) + else: + os.environ[key] = previous + + def _migrate_legacy_smart_routing(state: dict) -> dict: """Remove the former persisted opt-in and its permanent routing hooks.""" if smart_routing_v2.LEGACY_STATE_KEY not in state: @@ -2113,6 +2282,60 @@ def _managed_smart_routing_enabled(managed: dict | None, tool: str) -> bool: return agent_config.get("smart_routing_enabled") is True +def _managed_controls_model_source(managed: dict | None, tool: str) -> bool: + """Whether the managed config selects a provider or Hosted/static models for ``tool``. + + Managed ``unity_catalog_location`` intentionally remains outside this PR; the downstream + managed-location change owns interpreting and enforcing that source. + """ + if managed is None: + return False + return managed_supplies_models(managed, tool) or bool(managed_static_models(managed, tool)) + + +def _reject_configure_model_location(managed: dict | None, tools: list[str]) -> None: + """Reject a persisted model location when managed config owns a selected source.""" + for tool in tools: + if tool not in CAN_USE_CACHED_CONFIG_AGENTS: + continue + _reject_managed_source_override( + managed, + tool, + explicit_provider=None, + explicit_model_location=True, + ) + + +def _reject_managed_source_override( + managed: dict | None, + tool: str, + *, + explicit_provider: str | None, + explicit_model_location: bool, +) -> None: + """Reject explicit developer source flags when the admin selected a managed source.""" + if not _managed_controls_model_source(managed, tool): + return + display = TOOL_SPECS[tool]["display"] + managed_provider = managed_provider_service(managed or {}, tool) + if explicit_model_location: + source = f"provider {managed_provider}" if managed_provider else "Hosted/static models" + raise RuntimeError( + f"You cannot launch {display} with --model-location because your admin has " + f"specified managed {source}." + ) + if explicit_provider is not None: + if managed_provider: + raise RuntimeError( + f"You cannot launch {display} with provider {explicit_provider} because your " + f"admin has specified managed provider {managed_provider}." + ) + raise RuntimeError( + f"You cannot launch {display} with provider {explicit_provider} because your admin " + "has specified managed Hosted/static models." + ) + + def _launch_tool( tool_name: str, ctx: typer.Context, @@ -2135,7 +2358,9 @@ def _launch_tool( if provider is not None and parent_schema is not None: raise RuntimeError("--provider and --model-location cannot be used together.") if parent_schema is not None and not is_valid_catalog_schema(parent_schema): - raise RuntimeError("--model-location must be `.`.") + raise RuntimeError( + "--model-location must be a literal `.` identifier." + ) explicit_prompt = _has_explicit_prompt(ctx) smart_routing_enabled = smart_routing_v2.smart_routing_enabled() # Launchers such as isaac put their harness arguments after `--`, so the harness's own @@ -2147,12 +2372,21 @@ def _launch_tool( # `--model` is exposed by the claude and gemini launch commands. Under a provider it selects # which of the service's targets/tiers to launch on, rather than being rejected — see the # provider branch below. - # An explicit --workspace targets that workspace for this launch (and - # auto-configures it if unseen), so `ug claude --provider ... --workspace ...` - # works without a prior `ug configure`. - if workspace_url: - set_current_workspace(normalize_workspace_url(workspace_url)) - existing = load_state() + # Read an explicit target without making it current yet. Managed policy + # must accept the launch before the workspace selection is persisted. + target_workspace = normalize_workspace_url(workspace_url) if workspace_url else None + existing = load_workspace_state(target_workspace) if target_workspace else load_state() + explicit_provider = provider + explicit_model_location = parent_schema is not None + saved_provider = get_provider_service(existing, tool) + saved_model_location = get_model_location(existing, tool) + if explicit_model_location: + provider = None + elif explicit_provider is not None: + parent_schema = None + else: + provider = saved_provider + parent_schema = saved_model_location if provider is None else None # Workspaces configured with --use-pat export the profile's PAT as # DATABRICKS_BEARER up front so every auth check below (and the # launched agent itself) uses the static token instead of OAuth. @@ -2161,32 +2395,66 @@ def _launch_tool( existing.get("available_tools") or [] ) ensure_bootstrap_dependencies(tool) + coding_agent_config_feature_disabled = False + managed_config_checked = managed is not None + if (target_workspace is not None or needs_auto_configure) and existing.get("workspace"): + if not managed_config_checked: + managed, coding_agent_config_feature_disabled = _fetch_managed_config(existing) + managed_config_checked = True + _reject_disabled_agent(managed, tool) + _reject_managed_source_override( + managed, + tool, + explicit_provider=explicit_provider, + explicit_model_location=explicit_model_location, + ) + if target_workspace is not None: + set_current_workspace(target_workspace) if needs_auto_configure: - if custom_oauth is None: - _auto_configure_tool(tool) + if custom_oauth is not None and parent_schema is not None: + auto_managed = _auto_configure_tool( + tool, custom_oauth=custom_oauth, model_location=parent_schema + ) + elif custom_oauth is not None and explicit_provider is not None: + auto_managed = _auto_configure_tool( + tool, + custom_oauth=custom_oauth, + explicit_provider=explicit_provider, + ) + elif custom_oauth is not None: + auto_managed = _auto_configure_tool(tool, custom_oauth=custom_oauth) + elif parent_schema is not None: + auto_managed = _auto_configure_tool(tool, model_location=parent_schema) + elif explicit_provider is not None: + auto_managed = _auto_configure_tool(tool, explicit_provider=explicit_provider) else: - _auto_configure_tool(tool, custom_oauth=custom_oauth) + auto_managed = _auto_configure_tool(tool) + if not existing.get("workspace"): + managed, coding_agent_config_feature_disabled = auto_managed + managed_config_checked = True state = ensure_provider_state(tool) - # Remembered before the fallback below collapses the two cases: a managed config may not - # silently override a provider the user typed on the command line (it errors instead). - explicit_provider = provider - # An explicit --provider overrides the persisted choice; otherwise fall - # back to whatever `ug configure` saved for this tool. - provider = provider or get_provider_service(state, tool) + # Remembered above before persisted launch preferences were applied: a managed config may + # not silently override a provider the user typed on the command line (it errors instead). state = _migrate_legacy_smart_routing(state) # Fetched before `configure_shared_state` because it decides whether this agent may launch # at all and whether the model discovery below can be skipped. # Bare `ucode` already fetched one to choose the agent; refetching would double the # control-plane round trip and any fallback warning it printed. - coding_agent_config_feature_disabled = False - if managed is None: + if not managed_config_checked: managed, coding_agent_config_feature_disabled = _fetch_managed_config(state) + managed_config_checked = True # Checked before discovery, which can take tens of seconds, so a blocked launch fails fast. _reject_disabled_agent(managed, tool) # The environment switch remains a developer override; managed config is the workspace # policy equivalent and must take effect before launch options are computed. managed_smart_routing_enabled = _managed_smart_routing_enabled(managed, tool) smart_routing_enabled = smart_routing_enabled or managed_smart_routing_enabled + _reject_managed_source_override( + managed, + tool, + explicit_provider=explicit_provider, + explicit_model_location=explicit_model_location, + ) # Discovery exists to find models and isn't needed for managed config that already names them. managed_models_known = managed_supplies_models(managed, tool) # Re-fetch model lists on every launch so newly-added Databricks @@ -2199,7 +2467,7 @@ def _launch_tool( state["workspace"], profile=state.get("profile"), tools=[tool], - skip_model_discovery=bool(provider) or managed_models_known, + skip_model_discovery=(bool(provider) or bool(parent_schema) or managed_models_known), skip_preflight=skip_preflight, **configure_kwargs, ) @@ -2223,17 +2491,11 @@ def _launch_tool( print_note("No managed coding agent config found; using your own settings") if managed is not None: managed_provider = managed_provider_service(managed, tool) - if explicit_provider and managed_provider and managed_provider != explicit_provider: - # An explicit --provider that disagrees with the admin's is a hard error rather - # than a silent override: the user asked for something the managed config forbids, - # and quietly routing them elsewhere would hide it. - raise RuntimeError( - f"You cannot launch {TOOL_SPECS[tool]['display']} with provider " - f"{explicit_provider} because your admin has specified managed provider " - f"{managed_provider}." - ) - if managed_provider: + if _managed_controls_model_source(managed, tool): + # The managed source outranks saved developer preferences. Managed + # unity_catalog_location remains intentionally out of scope. provider = managed_provider + parent_schema = None if provider and parent_schema is not None: raise RuntimeError("--provider and --model-location cannot be used together.") # Checked after the managed config settles `provider`: an admin-set provider must trip this @@ -2243,6 +2505,14 @@ def _launch_tool( f"{TOOL_SPECS[tool]['display']} smart routing cannot be enabled with " "--provider. Launch without a Model Provider Service and try again." ) + # The initial bootstrap runs before a managed location is applied, and a bare launch's + # saved location is not exposed through the command-level environment scope. Recheck the + # native picker requirement now that the effective source is known. Supported versions + # stop at the cheap checker; only an actual blocker repeats the strict installer path. + location_uses_native_claude_discovery = tool == "claude" and parent_schema is not None + with _claude_native_model_discovery_environment(location_uses_native_claude_discovery): + if location_uses_native_claude_discovery and claude_agent.minimum_version_error(): + install_tool_binary("claude", strict=True) # Validate the provider service before launching — it must exist, be a # provider type this tool can route to (e.g. claude can't use an OpenAI # or Foundry service), and, for Bedrock, expose Claude models to pin. @@ -2307,6 +2577,10 @@ def _launch_tool( resolved_model, gemini_error = resolve_gemini_provider_model(state, provider, model) if gemini_error: raise RuntimeError(gemini_error) + elif parent_schema: + # The schema is the authoritative model source. Do not resolve or pin a model from + # global Hosted discovery; the agent discovers this location through the scoped header. + resolved_model = None else: # A managed default_model is the model the admin wants sessions to start on, so it goes # in as the explicit model rather than being applied afterwards: for codex the proto has @@ -2375,14 +2649,24 @@ def _launch_tool( # nothing. if managed is not None and not is_dry_run(): _download_managed_skills(managed, state) + # Launch-scoped choices live on a shallow copy so downstream agent fallbacks cannot revive + # a saved provider that an explicit --model-location overrode, and transient markers can + # never leak into a later state save. + launch_state = dict(state) + if explicit_model_location or ( + _managed_controls_model_source(managed, tool) and provider is None + ): + set_provider_service(launch_state, tool, None) if tool == "claude": if provider: - state["_claude_launch_provider"] = provider + launch_state["_claude_launch_provider"] = provider + elif parent_schema: + launch_state["_claude_launch_parent_schema"] = parent_schema elif tool == "codex": if provider: - state["_codex_launch_provider"] = provider + launch_state["_codex_launch_provider"] = provider elif parent_schema: - state["_codex_launch_parent_schema"] = parent_schema + launch_state["_codex_launch_parent_schema"] = parent_schema launch_options = _launch_options( tool, ctx.args, @@ -2394,8 +2678,11 @@ def _launch_tool( provider=provider, ) print_success(f"Starting {TOOL_SPECS[tool]['display']}") - with _managed_smart_routing_environment(managed, tool): - launch_agent(tool, state, ctx.args, options=launch_options) + with ( + _managed_smart_routing_environment(managed, tool), + _claude_native_model_discovery_environment(location_uses_native_claude_discovery), + ): + launch_agent(tool, launch_state, ctx.args, options=launch_options) except RuntimeError as exc: print_err(str(exc)) raise typer.Exit(1) from None @@ -2513,9 +2800,8 @@ def _launch_managed_default( workspace: str | None, ) -> None: """Route bare ``ucode`` by whether the workspace publishes a managed config.""" - if workspace: - set_current_workspace(normalize_workspace_url(workspace)) - state = load_state() + target_workspace = normalize_workspace_url(workspace) if workspace else None + state = load_workspace_state(target_workspace) if target_workspace else load_state() current = state.get("workspace") if not current: console.print(ctx.get_help()) @@ -2529,12 +2815,16 @@ def _launch_managed_default( with spinner("Loading..."): managed, coding_agent_config_feature_disabled = refresh_managed_config(state) if coding_agent_config_feature_disabled: + if target_workspace is not None: + set_current_workspace(target_workspace) print_note( "Run `ug configure` to set up your coding agents, then launch one with " "`ug ` (for example `ug claude`)." ) return if not managed: + if target_workspace is not None: + set_current_workspace(target_workspace) _print_no_managed_config_guidance() return # The budget tier can move the org to a cheaper agent, so it outranks the config's @@ -2567,6 +2857,13 @@ def _print_no_managed_config_guidance() -> None: ) +def _resolve_model_location_alias(model_location: str | None, parent: str | None) -> str | None: + """Resolve the hidden legacy ``--parent`` spelling without obscuring diagnostics.""" + if model_location is not None and parent is not None: + raise RuntimeError("Use only one of --model-location or --parent.") + return model_location if model_location is not None else parent + + @app.command( "codex", cls=_PromptAwareCommand, @@ -2587,9 +2884,14 @@ def codex_cmd( str | None, typer.Option( "--model-location", - help="Discover model services in `.`. Example: main.default", + help="Discover models from a literal Unity Catalog `.` location. " + "Overrides a saved provider for this launch.", ), ] = None, + parent: Annotated[ + str | None, + typer.Option("--parent", hidden=True), + ] = None, refresh: Annotated[ bool, typer.Option( @@ -2631,6 +2933,7 @@ def codex_cmd( ) -> None: """Launch Codex via Databricks.""" try: + model_location = _resolve_model_location_alias(model_location, parent) custom_oauth = _custom_oauth_config(client_id, redirect_url, scopes) except RuntimeError as exc: print_err(str(exc)) @@ -2676,9 +2979,14 @@ def claude_cmd( str | None, typer.Option( "--model-location", - help="Discover model services in `.`. Example: main.default", + help="Discover models from a literal Unity Catalog `.` location. " + "Overrides a saved provider for this launch.", ), ] = None, + parent: Annotated[ + str | None, + typer.Option("--parent", hidden=True), + ] = None, model: Annotated[ str | None, typer.Option( @@ -2739,6 +3047,7 @@ def claude_cmd( ) -> None: """Launch Claude Code via Databricks.""" try: + model_location = _resolve_model_location_alias(model_location, parent) custom_oauth = _custom_oauth_config(client_id, redirect_url, scopes) except RuntimeError as exc: print_err(str(exc)) @@ -2750,9 +3059,12 @@ def claude_cmd( claude_agent.disable_smart_routing(load_state()) print_success("Claude Code smart routing disabled; ug routing hooks removed") return - if enable_model_discovery or (model_location is not None and provider is None): - os.environ[claude_agent.GATEWAY_MODEL_DISCOVERY_ENV_VAR] = "1" - with _smart_routing_v2_flag(enable_smart_routing_flag): + with ( + _claude_native_model_discovery_environment( + enable_model_discovery or (model_location is not None and provider is None) + ), + _smart_routing_v2_flag(enable_smart_routing_flag), + ): with _disable_smart_routing_for_subcommand("claude", ctx): _launch_tool( "claude", @@ -2875,6 +3187,14 @@ def configure( help="Configure a comma-separated list of agents without prompting (e.g. claude,codex).", ), ] = None, + model_location: Annotated[ + str | None, + typer.Option( + "--model-location", + help="Persist a literal Unity Catalog `.` model location for this " + "workspace and selected Claude/Codex agents.", + ), + ] = None, workspace: Annotated[ str | None, typer.Option( @@ -3014,6 +3334,10 @@ def configure( set_verbosity(verbose) try: custom_oauth = _custom_oauth_config(client_id, redirect_url, scopes) + if model_location is not None and not is_valid_catalog_schema(model_location): + raise RuntimeError( + "--model-location must be a literal `.` identifier." + ) if custom_oauth is not None and use_pat: raise RuntimeError("--client-id cannot be combined with --use-pat.") install_databricks_cli() @@ -3046,6 +3370,8 @@ def configure( skip_kwargs["databricks_ai_tools_enabled"] = enable_databricks_ai_tools if custom_oauth is not None: skip_kwargs["custom_oauth"] = custom_oauth + if model_location is not None: + skip_kwargs["model_location"] = model_location # Set True only in the fully-interactive branch below; gates the optional # MCP setup prompt so flag-driven / scripted runs are never interrupted. fully_interactive = False diff --git a/src/ucode/state.py b/src/ucode/state.py index 3d209b51..4d9e7fed 100644 --- a/src/ucode/state.py +++ b/src/ucode/state.py @@ -47,9 +47,17 @@ def load_state() -> dict: workspace = full.get("current_workspace") if not workspace: return {} - ws_state = full.get("workspaces", {}).get(workspace, {}) - ws_state["workspace"] = workspace - return hydrate_state(ws_state) + return load_workspace_state(workspace, full_state=full) + + +def load_workspace_state(workspace: str, *, full_state: dict | None = None) -> dict: + """Load one workspace's state without changing the current workspace.""" + full = full_state if full_state is not None else load_full_state() + workspaces = full.get("workspaces") + raw = workspaces.get(workspace) if isinstance(workspaces, dict) else None + state = dict(raw) if isinstance(raw, dict) else {} + state["workspace"] = workspace + return hydrate_state(state) def save_state(state: dict) -> None: @@ -67,7 +75,7 @@ def save_state(state: dict) -> None: workspace = state.get("workspace") or full.get("current_workspace") if workspace: full["current_workspace"] = workspace - full["workspaces"][workspace] = hydrate_state(_without_managed_overlay(state)) + full["workspaces"][workspace] = hydrate_state(developer_state_from_resolved(state)) try: APP_DIR.mkdir(parents=True, exist_ok=True) STATE_PATH.write_text(json.dumps(full, indent=2), encoding="utf-8") @@ -75,15 +83,17 @@ def save_state(state: dict) -> None: raise RuntimeError(f"Failed to write state file: {STATE_PATH}") from exc -def _without_managed_overlay(state: dict) -> dict: +def developer_state_from_resolved(state: dict) -> dict: """Return ``state`` with managed-config values swapped back for the developer's own. Returns a new dict and leaves ``state`` untouched, so the caller keeps the layered values it - needs for rendering and repeated saves stay idempotent. + needs for rendering and repeated saves stay idempotent. Multi-agent configuration also uses + this between agents so one agent's managed overlay cannot become the next agent's developer + state. """ overlay = state.get(MANAGED_OVERLAY_KEY) if not isinstance(overlay, dict): - return state + return dict(state) persisted = {key: value for key, value in state.items() if key != MANAGED_OVERLAY_KEY} for key, value in overlay.items(): # A key the developer never set is dropped rather than persisted as None. @@ -300,6 +310,29 @@ def set_provider_service(state: dict, tool: str, full_name: str | None) -> dict: return state +def get_model_location(state: dict, tool: str) -> str | None: + """Return ``tool``'s persisted ``.`` model location.""" + locations = state.get("model_locations") + if not isinstance(locations, dict): + return None + location = locations.get(tool) + return location if isinstance(location, str) and location else None + + +def set_model_location(state: dict, tool: str, location: str | None) -> dict: + """Persist (or clear) ``tool``'s workspace-scoped model location.""" + locations = dict(state.get("model_locations") or {}) + if location: + locations[tool] = location + else: + locations.pop(tool, None) + if locations: + state["model_locations"] = locations + else: + state.pop("model_locations", None) + return state + + # The managed configuration's ``update_time`` last applied to this workspace's agents. A launch # compares a freshly fetched config against it to decide whether to re-apply, so it is written only # after an apply succeeds — never on a plain fetch. diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index 8f484cc1..c5acbe5b 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -372,11 +372,21 @@ def test_no_provider_header_without_flag(self): assert "Databricks-Model-Provider-Service" not in overlay["env"]["ANTHROPIC_CUSTOM_HEADERS"] def test_parent_adds_discovery_header(self): - overlay, _ = claude.render_overlay(WS, "s4", parent_schema="main.default") + overlay, _ = claude.render_overlay( + WS, + None, + claude_models={ + "opus": "system.ai.claude-opus-4-8", + "sonnet": "system.ai.claude-sonnet-4-6", + }, + parent_schema="main.default", + ) assert ( "Databricks-Model-Service-Parent-Schema: main.default" in overlay["env"]["ANTHROPIC_CUSTOM_HEADERS"] ) + assert "ANTHROPIC_DEFAULT_OPUS_MODEL" not in overlay["env"] + assert "ANTHROPIC_DEFAULT_SONNET_MODEL" not in overlay["env"] def test_bedrock_provider_pins_model_ids(self): provider_models = { diff --git a/tests/test_agents_init.py b/tests/test_agents_init.py index 8c85d429..4333bffe 100644 --- a/tests/test_agents_init.py +++ b/tests/test_agents_init.py @@ -481,6 +481,20 @@ def test_gemini_provider_resolution_error_raises(self, monkeypatch): agents_mod._configure_one("gemini", self._STATE, "c.s.g") +def test_configure_claude_allows_parent_location_without_global_model(monkeypatch): + state = {"workspace": "https://ws.databricks.com", "claude_models": {}} + captured = {} + + def fake_write_tool_config(state, model, **kwargs): + captured.update(model=model, parent_schema=kwargs.get("parent_schema")) + return state + + monkeypatch.setattr(agents_mod.claude, "write_tool_config", fake_write_tool_config) + + assert agents_mod.configure_tool("claude", state, parent_schema="main.models") is state + assert captured == {"model": None, "parent_schema": "main.models"} + + class TestResolveGeminiProviderModel: _STATE = {"workspace": "https://ws.databricks.com", "profile": None} diff --git a/tests/test_cli.py b/tests/test_cli.py index bff007c9..3c09ed71 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -442,6 +442,7 @@ def test_workspace_flag_sets_current_workspace(self): patches[5], patches[6], patches[7], + patch("ucode.cli.load_workspace_state", return_value=MINIMAL_STATE), patch("ucode.cli.set_current_workspace") as mock_set, ): result = runner.invoke( @@ -451,6 +452,72 @@ def test_workspace_flag_sets_current_workspace(self): assert result.exit_code == 0, result.output mock_set.assert_called_once_with("https://eng-ml-inference.staging.cloud.databricks.com") + def test_workspace_flag_rejection_does_not_change_current_workspace(self): + target = "https://target.databricks.com" + target_state = {**MINIMAL_STATE, "workspace": target} + managed = { + "enabled_agents": { + "claude": {"model_config": {"default_model": "system.ai.managed-model"}} + } + } + with ( + patch("ucode.cli.load_workspace_state", return_value=target_state), + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli._fetch_managed_config", return_value=(managed, False)), + patch("ucode.cli.set_current_workspace") as mock_set, + patch("ucode.cli.ensure_provider_state") as mock_provider_state, + patch("ucode.cli.configure_shared_state") as mock_shared, + ): + result = runner.invoke( + app, + [ + "claude", + "--workspace", + target, + "--model-location", + "main.models", + ], + ) + + assert result.exit_code == 1 + assert "--model-location" in _strip_ansi(result.output) + mock_set.assert_not_called() + mock_provider_state.assert_not_called() + mock_shared.assert_not_called() + + def test_workspace_flag_switches_after_policy_and_uses_target_preferences(self): + target = "https://target.databricks.com" + target_state = { + **MINIMAL_STATE, + "workspace": target, + "model_locations": {"claude": "target.models"}, + } + events: list[str] = [] + with ( + patch("ucode.cli.load_workspace_state", return_value=target_state) as mock_load_target, + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch( + "ucode.cli._fetch_managed_config", + side_effect=lambda state: events.append("validate") or (None, False), + ), + patch( + "ucode.cli.set_current_workspace", + side_effect=lambda workspace: events.append("switch"), + ) as mock_set, + patch("ucode.cli.ensure_provider_state", return_value=target_state), + patch("ucode.cli.configure_shared_state", return_value=target_state), + patch("ucode.cli.configure_tool", return_value=target_state) as mock_configure, + patch("ucode.cli.launch_agent") as mock_launch, + ): + result = runner.invoke(app, ["claude", "--workspace", target]) + + assert result.exit_code == 0, result.output + mock_load_target.assert_called_once_with(target) + assert events == ["validate", "switch"] + mock_set.assert_called_once_with(target) + assert mock_configure.call_args.kwargs["parent_schema"] == "target.models" + assert mock_launch.call_args.args[1]["_claude_launch_parent_schema"] == "target.models" + def test_no_workspace_flag_leaves_current_workspace(self): """Without --workspace, launch never reassigns the current workspace.""" patches = _patch_launch("claude") @@ -612,6 +679,7 @@ def test_codex_forwarded_model_is_not_printed_in_launch_summary( with ( patch("ucode.cli.ensure_bootstrap_dependencies"), patch("ucode.cli.load_state", return_value=state), + patch("ucode.cli.load_workspace_state", return_value=state), patch("ucode.cli.ensure_provider_state", return_value=state), patch("ucode.cli.configure_shared_state", return_value=state), patch( @@ -634,22 +702,55 @@ def test_codex_forwarded_model_is_not_printed_in_launch_summary( assert "Model: system.ai.gpt-5-6-luna" not in output assert mock_launch.call_args.args[2] == forwarded_args - def test_claude_enable_model_discovery_sets_ucode_env(self): - with patch("ucode.cli._launch_tool") as mock_launch: + def test_claude_enable_model_discovery_scopes_ucode_env(self, monkeypatch): + key = cli_mod.claude_agent.GATEWAY_MODEL_DISCOVERY_ENV_VAR + monkeypatch.setenv(key, "caller-value") + observed = [] + with patch( + "ucode.cli._launch_tool", + side_effect=lambda *_args, **_kwargs: observed.append(os.environ.get(key)), + ) as mock_launch: result = runner.invoke(app, ["claude", "--enable-model-discovery"]) assert result.exit_code == 0, result.output - assert os.environ["ENABLE_CLAUDE_CODE_GATEWAY_MODEL_DISCOVERY"] == "1" + assert observed == ["1"] + assert os.environ[key] == "caller-value" assert mock_launch.call_args.args[1].args == [] - def test_claude_model_location_is_forwarded(self): - with patch("ucode.cli._launch_tool") as mock_launch: + def test_claude_model_location_is_forwarded_with_scoped_discovery(self, monkeypatch): + key = cli_mod.claude_agent.GATEWAY_MODEL_DISCOVERY_ENV_VAR + monkeypatch.delenv(key, raising=False) + observed = [] + with patch( + "ucode.cli._launch_tool", + side_effect=lambda *_args, **_kwargs: observed.append(os.environ.get(key)), + ) as mock_launch: result = runner.invoke(app, ["claude", "--model-location", "main.default"]) assert result.exit_code == 0, result.output assert mock_launch.call_args.kwargs["parent_schema"] == "main.default" assert mock_launch.call_args.args[1].args == [] - assert os.environ["ENABLE_CLAUDE_CODE_GATEWAY_MODEL_DISCOVERY"] == "1" + assert observed == ["1"] + assert key not in os.environ + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_parent_and_model_location_are_mutually_exclusive(self, tool): + result = runner.invoke( + app, + [tool, "--model-location", "main.models", "--parent", "main.legacy"], + ) + + assert result.exit_code == 1 + assert "Use only one of --model-location or --parent" in result.output + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_launch_help_uses_model_location_and_hides_parent(self, tool): + result = runner.invoke(app, [tool, "--help"]) + + output = _strip_ansi(result.output) + assert result.exit_code == 0, result.output + assert "--model-location" in output + assert "--parent" not in output def test_codex_model_location_is_forwarded(self): with patch("ucode.cli._launch_tool") as mock_launch: @@ -682,7 +783,9 @@ def test_invalid_model_location_is_rejected(self, tool): result = runner.invoke(app, [tool, "--model-location", "main"]) assert result.exit_code == 1 - assert "--model-location must be `.`." in _strip_ansi(result.output) + assert "--model-location must be a literal `.` identifier." in _strip_ansi( + result.output + ) def test_claude_enable_model_discovery_is_hidden_from_help(self): result = runner.invoke(app, ["claude", "--help"]) @@ -908,6 +1011,7 @@ def test_forwarded_model_is_not_printed_in_launch_summary(self, forwarded_args): with ( patch("ucode.cli.ensure_bootstrap_dependencies"), patch("ucode.cli.load_state", return_value=state), + patch("ucode.cli.load_workspace_state", return_value=state), patch("ucode.cli.ensure_provider_state", return_value=state), patch("ucode.cli.configure_shared_state", return_value=state), patch( @@ -1144,6 +1248,329 @@ def test_model_location_sets_transient_codex_launch_marker(self): assert result.exit_code == 0, result.output assert mock_launch.call_args.args[1]["_codex_launch_parent_schema"] == "main.default" + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_bare_launch_reuses_saved_model_location(self, tool): + state = { + **MINIMAL_STATE, + "model_locations": {tool: "main.saved"}, + "claude_models": {}, + "codex_models": [], + } + with ( + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli.load_state", return_value=state), + patch("ucode.cli.ensure_provider_state", return_value=state), + patch("ucode.cli.configure_shared_state", return_value=state) as mock_shared, + patch( + "ucode.cli.resolve_launch_model", + return_value=(state, "system.ai.default"), + ) as mock_resolve, + patch("ucode.cli.configure_tool", return_value=state) as mock_configure, + patch("ucode.cli._fetch_managed_config", return_value=(None, False)), + patch("ucode.cli.launch_agent"), + ): + result = runner.invoke(app, [tool]) + + assert result.exit_code == 0, result.output + assert mock_shared.call_args.kwargs["skip_model_discovery"] is True + mock_resolve.assert_not_called() + assert mock_configure.call_args.args[2] is None + assert mock_configure.call_args.kwargs["parent_schema"] == "main.saved" + + @pytest.mark.parametrize("launch_error", [None, RuntimeError("launch failed")]) + def test_saved_claude_location_scopes_native_discovery(self, monkeypatch, launch_error): + key = cli_mod.claude_agent.GATEWAY_MODEL_DISCOVERY_ENV_VAR + monkeypatch.setenv(key, "caller-value") + observed = [] + state = { + **MINIMAL_STATE, + "model_locations": {"claude": "main.saved"}, + "claude_models": {}, + } + + def launch(*_args, **_kwargs): + observed.append(os.environ.get(key)) + if launch_error is not None: + raise launch_error + + with ( + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli.load_state", return_value=state), + patch("ucode.cli.ensure_provider_state", return_value=state), + patch("ucode.cli.configure_shared_state", return_value=state), + patch("ucode.cli.configure_tool", return_value=state), + patch("ucode.cli._fetch_managed_config", return_value=(None, False)), + patch("ucode.cli.launch_agent", side_effect=launch), + ): + result = runner.invoke(app, ["claude"]) + + assert result.exit_code == (1 if launch_error else 0), result.output + assert observed == ["1"] + assert os.environ[key] == "caller-value" + + def test_saved_claude_location_upgrades_old_native_discovery_version(self, monkeypatch): + key = cli_mod.claude_agent.GATEWAY_MODEL_DISCOVERY_ENV_VAR + monkeypatch.delenv(key, raising=False) + events = [] + state = { + **MINIMAL_STATE, + "model_locations": {"claude": "main.saved"}, + "claude_models": {}, + } + + def install(tool, *, strict): + events.append(("install", tool, strict, os.environ.get(key))) + return True + + def configure(*args, **kwargs): + events.append(("configure",)) + return args[1] + + def launch(*_args, **_kwargs): + events.append(("launch",)) + + with ( + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli.load_state", return_value=state), + patch("ucode.cli.ensure_provider_state", return_value=state), + patch("ucode.cli.configure_shared_state", return_value=state), + patch("ucode.cli.claude_agent.agent_version", return_value="2.1.247"), + patch("ucode.cli.install_tool_binary", side_effect=install) as mock_install, + patch("ucode.cli.configure_tool", side_effect=configure), + patch("ucode.cli._fetch_managed_config", return_value=(None, False)), + patch("ucode.cli.launch_agent", side_effect=launch), + ): + result = runner.invoke(app, ["claude"]) + + assert result.exit_code == 0, result.output + mock_install.assert_called_once_with("claude", strict=True) + assert events == [ + ("install", "claude", True, "1"), + ("configure",), + ("launch",), + ] + assert key not in os.environ + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_explicit_model_location_overrides_saved_provider_without_mutating_state(self, tool): + saved_provider = f"main.providers.{tool}" + state = { + **MINIMAL_STATE, + "model_locations": {tool: "main.saved"}, + "provider_services": {tool: saved_provider}, + } + with ( + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli.load_state", return_value=state), + patch("ucode.cli.ensure_provider_state", return_value=state), + patch("ucode.cli.configure_shared_state", return_value=state) as mock_shared, + patch( + "ucode.cli.resolve_launch_model", + return_value=(state, "system.ai.default"), + ), + patch("ucode.cli.configure_tool", return_value=state) as mock_configure, + patch("ucode.cli._fetch_managed_config", return_value=(None, False)), + patch("ucode.cli.launch_agent") as mock_launch, + ): + result = runner.invoke(app, [tool, "--model-location", "main.override"]) + + assert result.exit_code == 0, result.output + assert mock_shared.call_args.kwargs["skip_model_discovery"] is True + assert mock_configure.call_args.kwargs["provider"] is None + assert mock_configure.call_args.kwargs["parent_schema"] == "main.override" + launch_state = mock_launch.call_args.args[1] + if tool == "codex": + assert launch_state["_codex_launch_parent_schema"] == "main.override" + assert not launch_state.get("provider_services", {}).get(tool) + assert state["model_locations"][tool] == "main.saved" + assert state["provider_services"][tool] == saved_provider + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_explicit_provider_overrides_saved_model_location_without_mutating_state(self, tool): + state = {**MINIMAL_STATE, "model_locations": {tool: "main.saved"}} + provider = f"main.providers.{tool}" + with ( + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli.load_state", return_value=state), + patch("ucode.cli.ensure_provider_state", return_value=state), + patch("ucode.cli.configure_shared_state", return_value=state) as mock_shared, + patch("ucode.cli.resolve_provider_models", return_value=(None, None, False)), + patch("ucode.cli.configure_tool", return_value=state) as mock_configure, + patch("ucode.cli._fetch_managed_config", return_value=(None, False)), + patch("ucode.cli.launch_agent"), + ): + result = runner.invoke(app, [tool, "--provider", provider]) + + assert result.exit_code == 0, result.output + assert mock_shared.call_args.kwargs["skip_model_discovery"] is True + assert mock_configure.call_args.kwargs["provider"] == provider + assert mock_configure.call_args.kwargs["parent_schema"] is None + assert state["model_locations"][tool] == "main.saved" + assert "provider_services" not in state + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_explicit_provider_and_model_location_are_mutually_exclusive(self, tool): + result = runner.invoke( + app, + [ + tool, + "--provider", + "main.providers.service", + "--model-location", + "main.models", + ], + ) + + assert result.exit_code == 1 + assert "--provider and --model-location cannot be used together" in result.output + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_invalid_launch_model_location_fails_before_bootstrap(self, tool): + with patch("ucode.cli.ensure_bootstrap_dependencies") as mock_bootstrap: + result = runner.invoke(app, [tool, "--model-location", "not-a-schema"]) + + assert result.exit_code == 1 + assert "literal `.`" in _strip_ansi(result.output) + mock_bootstrap.assert_not_called() + + def test_managed_provider_conflict_reports_raw_explicit_provider(self): + state = dict(MINIMAL_STATE) + managed = { + "enabled_agents": { + "claude": { + "model_config": { + "model_provider_service": "main.admin.anthropic", + } + } + } + } + with ( + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli.load_state", return_value=state), + patch("ucode.cli.ensure_provider_state", return_value=state), + patch("ucode.cli.configure_shared_state", return_value=state), + patch("ucode.cli._fetch_managed_config", return_value=(managed, False)), + patch("ucode.cli._fetch_budget_recommendation", return_value=None), + patch("ucode.cli.configure_tool", return_value=state), + patch("ucode.cli.launch_agent"), + ): + result = runner.invoke( + app, + ["claude", "--provider", "main.user.anthropic"], + ) + + assert result.exit_code == 1 + assert "provider main.user.anthropic" in _strip_ansi(result.output) + assert "managed provider main.admin.anthropic" in _strip_ansi(result.output) + + def test_managed_provider_rejects_redundant_explicit_provider(self): + state = dict(MINIMAL_STATE) + provider = "main.admin.anthropic" + managed = { + "enabled_agents": {"claude": {"model_config": {"model_provider_service": provider}}} + } + with ( + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli.load_state", return_value=state), + patch("ucode.cli.ensure_provider_state", return_value=state), + patch("ucode.cli._fetch_managed_config", return_value=(managed, False)), + patch("ucode.cli.configure_shared_state") as mock_shared, + ): + result = runner.invoke(app, ["claude", "--provider", provider]) + + assert result.exit_code == 1 + assert f"provider {provider}" in _strip_ansi(result.output) + mock_shared.assert_not_called() + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + @pytest.mark.parametrize( + ("option", "expected"), + [ + (["--provider", "main.user.provider"], "provider main.user.provider"), + (["--model-location", "main.models"], "--model-location"), + ], + ) + @pytest.mark.parametrize( + "model_config", + [ + {"default_model": "system.ai.managed-model"}, + {"model_services": ["system.ai.managed-model"]}, + ], + ids=["hosted-default", "static-list"], + ) + def test_managed_hosted_source_rejects_explicit_source_override( + self, tool, option, expected, model_config + ): + state = dict(MINIMAL_STATE) + managed = {"enabled_agents": {tool: {"model_config": model_config}}} + with ( + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli.load_state", return_value=state), + patch("ucode.cli.ensure_provider_state", return_value=state), + patch("ucode.cli._fetch_managed_config", return_value=(managed, False)), + patch("ucode.cli.configure_shared_state") as mock_shared, + patch("ucode.cli.configure_tool") as mock_configure, + patch("ucode.cli.launch_agent") as mock_launch, + ): + result = runner.invoke(app, [tool, *option]) + + output = _strip_ansi(result.output) + assert result.exit_code == 1 + assert expected in output + assert "admin" in output + mock_shared.assert_not_called() + mock_configure.assert_not_called() + mock_launch.assert_not_called() + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + @pytest.mark.parametrize( + "model_config", + [ + {"default_model": "system.ai.managed-model"}, + {"model_services": ["system.ai.managed-model"]}, + ], + ids=["hosted-default", "static-list"], + ) + def test_managed_hosted_source_overrides_saved_provider_for_bare_launch( + self, tool, model_config + ): + saved_provider = f"main.user.{tool}" + state = { + **MINIMAL_STATE, + "provider_services": {tool: saved_provider}, + } + managed = {"enabled_agents": {tool: {"model_config": model_config}}} + with ( + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli.load_state", return_value=state), + patch("ucode.cli.ensure_provider_state", return_value=state), + patch("ucode.cli._fetch_managed_config", return_value=(managed, False)), + patch("ucode.cli._fetch_budget_recommendation", return_value=None), + patch("ucode.cli.configure_shared_state", return_value=state), + patch( + "ucode.cli.resolve_launch_model", + side_effect=lambda selected_tool, resolved, model: ( + resolved, + model or "system.ai.managed-model", + ), + ), + patch("ucode.cli.resolve_provider_models") as mock_resolve_provider, + patch( + "ucode.cli.configure_tool", side_effect=lambda *args, **kwargs: args[1] + ) as mock_configure, + patch("ucode.cli.launch_agent") as mock_launch, + ): + result = runner.invoke(app, [tool]) + + assert result.exit_code == 0, result.output + mock_resolve_provider.assert_not_called() + assert mock_configure.call_args.kwargs["provider"] is None + assert mock_configure.call_args.kwargs["parent_schema"] is None + launch_state = mock_launch.call_args.args[1] + assert not launch_state.get("provider_services", {}).get(tool) + assert f"_{tool}_launch_provider" not in launch_state + assert state["provider_services"][tool] == saved_provider + class TestGeminiProviderLaunch: @staticmethod @@ -2152,6 +2579,7 @@ def test_launch_autoconfigures_without_test_prompt(self, tool, has_workspace): "ucode.cli.configure_single_tool", return_value=configured_state ) as mock_configure, patch("ucode.cli.ensure_provider_state", return_value=configured_state), + patch("ucode.cli.refresh_managed_config", return_value=(None, False)), patch("ucode.cli._fetch_managed_config", return_value=(None, False)), patch("ucode.cli.configure_tool", return_value=configured_state), patch("ucode.cli.restore_file") as mock_restore, @@ -2165,15 +2593,205 @@ def test_launch_autoconfigures_without_test_prompt(self, tool, has_workspace): mock_launch.assert_called_once() assert mock_launch.call_args.args[:2] == (tool, configured_state) - def test_triggers_when_no_workspace(self): - """Auto-configure runs when state has no workspace.""" - empty_state = {} - configured_state = {**MINIMAL_STATE} + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_fresh_model_location_launch_autoconfigures_without_global_models(self, tool): + configured_state = { + **MINIMAL_STATE, + "available_tools": [tool], + "claude_models": {}, + "codex_models": [], + } with ( - patch("ucode.cli.ensure_bootstrap_dependencies") as mock_bootstrap, - patch("ucode.cli.load_state", return_value=empty_state), - patch("ucode.cli._auto_configure_tool") as mock_auto, - patch("ucode.cli.configure_shared_state", return_value=MINIMAL_STATE), + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli.load_state", return_value={}), + patch("ucode.cli._auto_configure_tool", return_value=(None, False)) as mock_auto, + patch("ucode.cli.ensure_provider_state", return_value=configured_state), + patch("ucode.cli._fetch_managed_config", return_value=(None, False)), + patch("ucode.cli.configure_shared_state", return_value=configured_state) as mock_shared, + patch("ucode.cli.resolve_launch_model") as mock_resolve, + patch("ucode.cli.configure_tool", return_value=configured_state), + patch("ucode.cli.launch_agent") as mock_launch, + ): + result = runner.invoke(app, [tool, "--model-location", "main.models"]) + + assert result.exit_code == 0, result.output + mock_auto.assert_called_once_with(tool, model_location="main.models") + assert mock_shared.call_args.kwargs["skip_model_discovery"] is True + mock_resolve.assert_not_called() + mock_launch.assert_called_once() + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_existing_workspace_rejects_managed_source_before_autoconfigure(self, tool): + state = {**MINIMAL_STATE, "available_tools": []} + original_state = json.loads(json.dumps(state)) + managed = { + "enabled_agents": {tool: {"model_config": {"default_model": "system.ai.managed-model"}}} + } + with ( + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli.load_state", return_value=state), + patch("ucode.cli._fetch_managed_config", return_value=(managed, False)) as mock_fetch, + patch("ucode.cli._auto_configure_tool", return_value=(None, False)) as mock_auto, + patch("ucode.cli.configure_shared_state") as mock_shared, + patch("ucode.cli.configure_tool") as mock_configure, + patch("ucode.cli.configure_single_tool") as mock_configure_single, + patch("ucode.cli.save_state") as mock_save, + patch("ucode.cli.ensure_provider_state") as mock_provider_state, + ): + result = runner.invoke(app, [tool, "--model-location", "main.models"]) + + assert result.exit_code == 1 + assert "--model-location" in _strip_ansi(result.output) + mock_fetch.assert_called_once_with(state) + mock_auto.assert_not_called() + mock_shared.assert_not_called() + mock_configure.assert_not_called() + mock_configure_single.assert_not_called() + mock_save.assert_not_called() + mock_provider_state.assert_not_called() + assert state == original_state + assert state["available_tools"] == [] + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_true_first_run_rejects_managed_source_before_agent_writes(self, tool): + configured_state = { + **MINIMAL_STATE, + "available_tools": [], + "claude_models": {}, + "codex_models": [], + } + original_state = json.loads(json.dumps(configured_state)) + managed = { + "enabled_agents": { + tool: {"model_config": {"model_services": ["system.ai.managed-model"]}} + } + } + with ( + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli.load_state", return_value={}), + patch( + "ucode.cli._prompt_for_configuration", + return_value=(MINIMAL_STATE["workspace"], None), + ), + patch("ucode.cli.configure_shared_state", return_value=configured_state) as mock_shared, + patch( + "ucode.cli.refresh_managed_config", return_value=(managed, False) + ) as mock_refresh, + patch("ucode.cli._fetch_managed_config") as mock_fetch, + patch("ucode.cli.configure_tool") as mock_configure, + patch("ucode.cli.configure_single_tool") as mock_configure_single, + patch("ucode.cli.save_state") as mock_save, + patch("ucode.cli.ensure_provider_state") as mock_provider_state, + ): + result = runner.invoke(app, [tool, "--model-location", "main.models"]) + + assert result.exit_code == 1 + output = _strip_ansi(result.output) + assert "--model-location" in output + assert "admin" in output + mock_shared.assert_called_once_with( + MINIMAL_STATE["workspace"], + profile=None, + tools=[tool], + skip_model_discovery=True, + persist=False, + ) + mock_refresh.assert_called_once_with(configured_state) + mock_fetch.assert_not_called() + mock_configure.assert_not_called() + mock_configure_single.assert_not_called() + mock_save.assert_not_called() + mock_provider_state.assert_not_called() + assert configured_state == original_state + assert configured_state["available_tools"] == [] + assert "model_locations" not in configured_state + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_prompted_first_run_carries_single_policy_snapshot_past_agent_writes(self, tool): + configured_state = { + **MINIMAL_STATE, + "available_tools": [], + "claude_models": {}, + "codex_models": [], + } + second_policy = { + "enabled_agents": { + tool: {"model_config": {"default_model": "system.ai.new-managed-model"}} + } + } + events: list[str] = [] + + def fetch_policy(state): + events.append("fetch") + return [(None, False), (second_policy, False)][events.count("fetch") - 1] + + def write_agent(*args, **kwargs): + events.append("write") + return args[1] + + with ( + patch("ucode.cli.ensure_bootstrap_dependencies"), + patch("ucode.cli.load_state", return_value={}), + patch( + "ucode.cli._prompt_for_configuration", + return_value=(MINIMAL_STATE["workspace"], None), + ), + patch("ucode.cli.configure_shared_state", return_value=configured_state), + patch("ucode.cli.refresh_managed_config", side_effect=fetch_policy) as mock_refresh, + patch("ucode.cli.configure_tool", side_effect=write_agent), + patch("ucode.cli.save_state"), + patch("ucode.cli.ensure_provider_state", return_value=configured_state), + patch("ucode.cli.launch_agent") as mock_launch, + ): + result = runner.invoke(app, [tool, "--model-location", "main.models"]) + + assert result.exit_code == 0, result.output + assert mock_refresh.call_count == 1 + assert events[0] == "fetch" + assert events.count("fetch") == 1 + assert events.count("write") == 2 + mock_launch.assert_called_once() + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_model_location_autoconfigure_is_launch_scoped(self, tool): + existing_state = {"workspace": MINIMAL_STATE["workspace"]} + configured_state = { + **MINIMAL_STATE, + "available_tools": [], + "claude_models": {}, + "codex_models": [], + } + saved_states = [] + with ( + patch("ucode.cli.load_state", return_value=existing_state), + patch("ucode.cli.configure_shared_state", return_value=configured_state) as mock_shared, + patch("ucode.cli.configure_tool", return_value=configured_state) as mock_configure, + patch( + "ucode.cli.save_state", + side_effect=lambda state: saved_states.append(json.loads(json.dumps(state))), + ), + ): + cli_mod._auto_configure_tool(tool, model_location="main.models") + + mock_shared.assert_called_once_with( + MINIMAL_STATE["workspace"], + profile=None, + tools=[tool], + skip_model_discovery=True, + ) + mock_configure.assert_called_once_with(tool, configured_state, parent_schema="main.models") + assert saved_states[-1]["available_tools"] == [tool] + assert "model_locations" not in saved_states[-1] + + def test_triggers_when_no_workspace(self): + """Auto-configure runs when state has no workspace.""" + empty_state = {} + configured_state = {**MINIMAL_STATE} + with ( + patch("ucode.cli.ensure_bootstrap_dependencies") as mock_bootstrap, + patch("ucode.cli.load_state", return_value=empty_state), + patch("ucode.cli._auto_configure_tool", return_value=(None, False)) as mock_auto, + patch("ucode.cli.configure_shared_state", return_value=MINIMAL_STATE), patch( "ucode.cli.ensure_provider_state", return_value=configured_state, @@ -2270,6 +2888,383 @@ def test_cursor_launch_uses_unity_gateway_branding(): class TestConfigureAgentFlag: + def test_help_lists_model_location(self): + result = runner.invoke(app, ["configure", "--help"]) + + assert result.exit_code == 0, result.output + assert "--model-location" in _strip_ansi(result.output) + + def test_invalid_model_location_fails_before_install(self): + with patch("ucode.cli.install_databricks_cli") as mock_install: + result = runner.invoke( + app, + ["configure", "--agents", "claude,codex", "--model-location", "main"], + ) + + assert result.exit_code == 1 + assert "literal `.`" in _strip_ansi(result.output) + mock_install.assert_not_called() + + def test_model_location_forwards_to_selected_agents(self): + with ( + patch("ucode.cli.install_databricks_cli"), + patch("ucode.cli.configure_workspace_command") as mock_cfg, + ): + result = runner.invoke( + app, + [ + "configure", + "--agents", + "claude,codex", + "--model-location", + "main.models", + ], + ) + + assert result.exit_code == 0, result.output + mock_cfg.assert_called_once_with( + selected_tools=["claude", "codex"], + model_location="main.models", + ) + + @pytest.mark.parametrize("agent_option", ["--agent", "--agents"]) + @pytest.mark.parametrize( + "model_config", + [ + {"default_model": "system.ai.managed-model"}, + {"model_services": ["system.ai.managed-model"]}, + ], + ids=["hosted-default", "static-list"], + ) + def test_model_location_rejects_cached_managed_source_before_writes( + self, agent_option, model_config + ): + managed = {"enabled_agents": {"claude": {"model_config": model_config}}} + state = { + **MINIMAL_STATE, + "model_locations": {"claude": "old.models"}, + "provider_services": {"claude": "old.providers.anthropic"}, + } + original_state = json.loads(json.dumps(state)) + with ( + patch("ucode.cli.install_databricks_cli"), + patch("ucode.cli.install_tool_binary"), + patch("ucode.cli.load_state", return_value=state), + patch("ucode.cli.load_managed_state", return_value=managed), + patch("ucode.cli._configure_shared_workspace_states") as mock_shared, + patch("ucode.cli.configure_tool") as mock_configure, + patch("ucode.cli.configure_selected_tools") as mock_configure_selected, + patch("ucode.cli.save_state") as mock_save, + ): + result = runner.invoke( + app, + [ + "configure", + agent_option, + "claude", + "--workspace", + MINIMAL_STATE["workspace"], + "--model-location", + "main.models", + ], + ) + + assert result.exit_code == 1 + assert "--model-location" in _strip_ansi(result.output) + mock_shared.assert_not_called() + mock_configure.assert_not_called() + mock_configure_selected.assert_not_called() + mock_save.assert_not_called() + assert state == original_state + + @pytest.mark.parametrize("agent_option", ["--agent", "--agents"]) + def test_model_location_rechecks_fresh_managed_source_before_agent_writes(self, agent_option): + managed = { + "enabled_agents": { + "claude": {"model_config": {"default_model": "system.ai.managed-model"}} + } + } + state = { + **MINIMAL_STATE, + "model_locations": {"claude": "old.models"}, + "provider_services": {"claude": "old.providers.anthropic"}, + } + original_state = json.loads(json.dumps(state)) + with ( + patch("ucode.cli.install_databricks_cli"), + patch("ucode.cli.install_tool_binary"), + patch("ucode.cli.load_managed_state", return_value=None), + patch( + "ucode.cli._configure_shared_workspace_states", return_value=[state] + ) as mock_shared, + patch("ucode.cli.refresh_managed_config", return_value=(managed, False)), + patch("ucode.cli.configure_tool") as mock_configure, + patch("ucode.cli.configure_selected_tools") as mock_configure_selected, + patch("ucode.cli.save_state") as mock_save, + ): + result = runner.invoke( + app, + [ + "configure", + agent_option, + "claude", + "--workspace", + MINIMAL_STATE["workspace"], + "--model-location", + "main.models", + ], + ) + + assert result.exit_code == 1 + assert "--model-location" in _strip_ansi(result.output) + mock_shared.assert_called_once() + mock_configure.assert_not_called() + mock_configure_selected.assert_not_called() + mock_save.assert_not_called() + assert state == original_state + + @pytest.mark.parametrize( + "gemini_model_config", + [ + {"default_model": "system.ai.gemini-2-5-pro"}, + {"model_services": ["system.ai.gemini-2-5-pro"]}, + ], + ids=["default-model", "static-list"], + ) + def test_model_location_ignores_cached_managed_gemini_source(self, gemini_model_config): + managed = { + "enabled_agents": { + "claude": {}, + "codex": {}, + "gemini": {"model_config": gemini_model_config}, + } + } + state = {**MINIMAL_STATE, "available_tools": []} + with ( + patch("ucode.cli.install_databricks_cli"), + patch("ucode.cli.install_tool_binary"), + patch("ucode.cli.load_managed_state", return_value=managed), + patch( + "ucode.cli._configure_shared_workspace_states", return_value=[state] + ) as mock_shared, + patch("ucode.cli.refresh_managed_config", return_value=(None, False)), + patch("ucode.cli.check_gateway_endpoint", return_value=True), + patch( + "ucode.cli._configure_tools_with_model_location", return_value=state + ) as mock_configure, + ): + result = runner.invoke( + app, + [ + "configure", + "--agents", + "claude,codex,gemini", + "--workspace", + MINIMAL_STATE["workspace"], + "--model-location", + "main.models", + ], + ) + + assert result.exit_code == 0, result.output + mock_shared.assert_called_once() + mock_configure.assert_called_once_with( + state, + ["claude", "codex", "gemini"], + "main.models", + install_ai_tools=True, + ) + + @pytest.mark.parametrize( + "agent_args", [["--agents", "claude,codex"], []], ids=["selected", "managed-enabled"] + ) + @pytest.mark.parametrize( + "gemini_model_config", + [ + {"default_model": "system.ai.gemini-2-5-pro"}, + {"model_services": ["system.ai.gemini-2-5-pro"]}, + ], + ids=["default-model", "static-list"], + ) + def test_model_location_configures_fallback_tools_with_fresh_managed_gemini_source( + self, agent_args, gemini_model_config + ): + enabled_agents = {"gemini": {"model_config": gemini_model_config}} + if not agent_args: + enabled_agents = {"claude": {}, "codex": {}, **enabled_agents} + managed = {"enabled_agents": enabled_agents} + state = {**MINIMAL_STATE, "available_tools": []} + saved_states: list[dict] = [] + with ( + patch("ucode.cli.install_databricks_cli"), + patch("ucode.cli.install_tool_binary"), + patch("ucode.cli.load_managed_state", return_value=None), + patch( + "ucode.cli._configure_shared_workspace_states", return_value=[state] + ) as mock_shared, + patch("ucode.cli.refresh_managed_config", return_value=(managed, False)), + patch("ucode.cli.check_gateway_endpoint", return_value=True) as mock_available, + patch("ucode.cli.resolve_state", wraps=cli_mod.resolve_state) as mock_resolve, + patch( + "ucode.cli.configure_tool", side_effect=lambda tool, current, **kwargs: current + ) as mock_write, + patch( + "ucode.cli.configure_selected_tools", + side_effect=lambda current, *args, **kwargs: current, + ) as mock_managed, + patch( + "ucode.cli.save_state", + side_effect=lambda current: saved_states.append(json.loads(json.dumps(current))), + ), + patch("ucode.cli.install_databricks_ai_tools_for_agents"), + ): + result = runner.invoke( + app, + [ + "configure", + *agent_args, + "--workspace", + MINIMAL_STATE["workspace"], + "--model-location", + "main.models", + ], + ) + + assert result.exit_code == 0, result.output + mock_shared.assert_called_once() + assert [call.args[0] for call in mock_write.call_args_list] == ["claude", "codex"] + assert all( + call.kwargs["parent_schema"] == "main.models" for call in mock_write.call_args_list + ) + assert {call.args[2] for call in mock_resolve.call_args_list} >= {"claude", "codex"} + mock_managed.assert_called_once() + assert mock_managed.call_args.args[1] == ["gemini"] + mock_available.assert_called_once() + assert saved_states[-1]["model_locations"] == { + "claude": "main.models", + "codex": "main.models", + } + + def test_later_fallback_save_does_not_persist_earlier_managed_agent_overlay(self): + managed = { + "enabled_agents": { + "claude": { + "model_config": { + "default_model": "system.ai.managed-claude", + "model_provider_service": "main.providers.managed-anthropic", + } + }, + "codex": {}, + } + } + state = {**MINIMAL_STATE, "available_tools": []} + saved_states: list[dict] = [] + + def configure_managed(current, *args, **kwargs): + return {**current, "available_tools": ["claude"]} + + with ( + patch("ucode.cli.install_databricks_cli"), + patch("ucode.cli.install_tool_binary"), + patch("ucode.cli.load_managed_state", return_value=None), + patch("ucode.cli._configure_shared_workspace_states", return_value=[state]), + patch("ucode.cli.refresh_managed_config", return_value=(managed, False)), + patch("ucode.cli.check_gateway_endpoint", return_value=True), + patch("ucode.cli.resolve_state", wraps=cli_mod.resolve_state) as mock_resolve, + patch( + "ucode.cli.configure_selected_tools", side_effect=configure_managed + ) as mock_managed, + patch("ucode.cli.configure_tool", side_effect=lambda tool, current, **kwargs: current), + patch( + "ucode.cli.save_state", + side_effect=lambda current: saved_states.append(json.loads(json.dumps(current))), + ), + patch("ucode.cli.install_databricks_ai_tools_for_agents"), + ): + result = runner.invoke( + app, + [ + "configure", + "--agents", + "codex", + "--workspace", + MINIMAL_STATE["workspace"], + "--model-location", + "main.models", + ], + ) + + assert result.exit_code == 0, result.output + assert mock_managed.call_args.args[0]["provider_services"]["claude"] == ( + "main.providers.managed-anthropic" + ) + codex_input = mock_resolve.call_args_list[1].args[1] + assert "_managed_overlay" not in codex_input + assert "claude_default_model" not in codex_input + assert "provider_services" not in codex_input + assert saved_states[-1]["model_locations"] == {"codex": "main.models"} + assert set(saved_states[-1]["available_tools"]) == {"claude", "codex"} + assert "claude_default_model" not in saved_states[-1] + assert "provider_services" not in saved_states[-1] + + def test_managed_gemini_provider_does_not_restore_fallback_agent_providers(self): + managed = { + "enabled_agents": { + "gemini": { + "model_config": {"model_provider_service": "main.providers.managed-gemini"} + } + } + } + state = { + **MINIMAL_STATE, + "available_tools": [], + "provider_services": { + "claude": "main.providers.stale-anthropic", + "codex": "main.providers.stale-openai", + }, + } + saved_states: list[dict] = [] + with ( + patch("ucode.cli.install_databricks_cli"), + patch("ucode.cli.install_tool_binary"), + patch("ucode.cli.load_managed_state", return_value=None), + patch("ucode.cli._configure_shared_workspace_states", return_value=[state]), + patch("ucode.cli.refresh_managed_config", return_value=(managed, False)), + patch("ucode.cli.check_gateway_endpoint", return_value=True), + patch( + "ucode.cli.configure_selected_tools", + side_effect=lambda current, *args, **kwargs: current, + ) as mock_managed, + patch("ucode.cli.configure_tool", side_effect=lambda tool, current, **kwargs: current), + patch( + "ucode.cli.save_state", + side_effect=lambda current: saved_states.append(json.loads(json.dumps(current))), + ), + patch("ucode.cli.install_databricks_ai_tools_for_agents"), + ): + result = runner.invoke( + app, + [ + "configure", + "--agents", + "claude,codex", + "--workspace", + MINIMAL_STATE["workspace"], + "--model-location", + "main.models", + ], + ) + + assert result.exit_code == 0, result.output + assert mock_managed.call_args.args[0]["provider_services"]["gemini"] == ( + "main.providers.managed-gemini" + ) + assert saved_states[-1]["model_locations"] == { + "claude": "main.models", + "codex": "main.models", + } + assert "provider_services" not in saved_states[-1] + def test_no_flag_calls_configure_all(self): with ( patch("ucode.cli.install_databricks_cli"), @@ -2722,6 +3717,267 @@ def test_selected_tools_skip_picker(self, monkeypatch): assert install_calls == ["claude", "codex"] assert configured == [["claude", "codex"]] + def test_model_location_persists_clears_selected_providers_and_scopes_configs( + self, monkeypatch + ): + state = { + **MINIMAL_STATE, + "model_locations": {"codex": "other.models"}, + "provider_services": { + "claude": "main.providers.anthropic", + "codex": "main.providers.openai", + "gemini": "main.providers.gemini", + }, + } + monkeypatch.setattr(cli_mod, "configure_shared_state", lambda *a, **k: state) + monkeypatch.setattr(cli_mod, "check_gateway_endpoint", lambda *a, **k: True) + monkeypatch.setattr(cli_mod, "install_tool_binary", lambda *a, **k: True) + monkeypatch.setattr(cli_mod, "install_databricks_ai_tools_for_agents", lambda *a, **k: None) + monkeypatch.setattr(cli_mod, "configure_selected_tools", lambda s, tools: s) + configure_calls = [] + monkeypatch.setattr( + cli_mod, + "configure_tool", + lambda *args, **kwargs: configure_calls.append((args, kwargs)) or args[1], + ) + + assert ( + cli_mod.configure_workspace_command( + selected_tools=["claude"], + workspaces=[("https://example.databricks.com", None)], + model_location="main.models", + ) + == 0 + ) + + configured_state = configure_calls[0][0][1] + assert configured_state["model_locations"] == { + "claude": "main.models", + "codex": "other.models", + } + assert configured_state["provider_services"] == { + "codex": "main.providers.openai", + "gemini": "main.providers.gemini", + } + assert state["model_locations"] == {"codex": "other.models"} + assert state["provider_services"]["claude"] == "main.providers.anthropic" + assert [(args[0], kwargs["parent_schema"]) for args, kwargs in configure_calls] == [ + ("claude", "main.models"), + ] + + def test_reconfigure_without_model_location_clears_saved_location(self, monkeypatch): + state = { + **MINIMAL_STATE, + "model_locations": {"claude": "main.models", "codex": "other.models"}, + "provider_services": {"claude": "main.providers.anthropic"}, + } + monkeypatch.setattr(cli_mod, "configure_shared_state", lambda *a, **k: state) + monkeypatch.setattr(cli_mod, "check_gateway_endpoint", lambda *a, **k: True) + monkeypatch.setattr(cli_mod, "install_tool_binary", lambda *a, **k: True) + monkeypatch.setattr(cli_mod, "install_databricks_ai_tools_for_agents", lambda *a, **k: None) + configured_states = [] + monkeypatch.setattr( + cli_mod, + "configure_selected_tools", + lambda s, tools, **kwargs: configured_states.append(s) or s, + ) + + assert ( + cli_mod.configure_workspace_command( + selected_tools=["claude"], + workspaces=[("https://example.databricks.com", None)], + ) + == 0 + ) + + assert configured_states[0]["model_locations"] == {"codex": "other.models"} + assert configured_states[0]["provider_services"] == {"claude": "main.providers.anthropic"} + assert state["model_locations"] == { + "claude": "main.models", + "codex": "other.models", + } + + def test_model_location_persists_only_after_each_agent_succeeds(self, monkeypatch): + state = { + **MINIMAL_STATE, + "model_locations": { + "claude": "old.claude_models", + "codex": "old.codex_models", + }, + "provider_services": { + "claude": "old.providers.anthropic", + "codex": "old.providers.openai", + }, + } + saved_states = [] + monkeypatch.setattr(cli_mod, "configure_shared_state", lambda *a, **k: state) + monkeypatch.setattr(cli_mod, "install_tool_binary", lambda *a, **k: True) + monkeypatch.setattr( + cli_mod, + "save_state", + lambda value: saved_states.append(json.loads(json.dumps(value))), + ) + + def configure(tool, candidate, **kwargs): + if tool == "codex": + raise RuntimeError("codex write failed") + return candidate + + monkeypatch.setattr(cli_mod, "configure_tool", configure) + + with pytest.raises(RuntimeError, match="codex write failed"): + cli_mod.configure_workspace_command( + selected_tools=["claude", "codex"], + workspaces=[("https://example.databricks.com", None)], + model_location="main.models", + ) + + assert saved_states[-1]["model_locations"] == { + "claude": "main.models", + "codex": "old.codex_models", + } + assert saved_states[-1]["provider_services"] == {"codex": "old.providers.openai"} + + def test_codex_model_location_is_scoped_without_changing_claude(self, monkeypatch): + state = { + **MINIMAL_STATE, + "provider_services": { + "claude": "main.providers.anthropic", + "codex": "main.providers.openai", + }, + } + monkeypatch.setattr(cli_mod, "configure_shared_state", lambda *a, **k: state) + monkeypatch.setattr(cli_mod, "check_gateway_endpoint", lambda *a, **k: True) + monkeypatch.setattr(cli_mod, "install_tool_binary", lambda *a, **k: True) + monkeypatch.setattr(cli_mod, "install_databricks_ai_tools_for_agents", lambda *a, **k: None) + monkeypatch.setattr(cli_mod, "configure_selected_tools", lambda s, tools: s) + configure_calls = [] + monkeypatch.setattr( + cli_mod, + "configure_tool", + lambda *args, **kwargs: configure_calls.append((args, kwargs)) or args[1], + ) + + assert ( + cli_mod.configure_workspace_command( + selected_tools=["codex"], + workspaces=[("https://example.databricks.com", None)], + model_location="main.models", + ) + == 0 + ) + + configured_state = configure_calls[0][0][1] + assert configured_state["model_locations"] == {"codex": "main.models"} + assert configured_state["provider_services"] == {"claude": "main.providers.anthropic"} + assert "model_locations" not in state + assert state["provider_services"] == { + "claude": "main.providers.anthropic", + "codex": "main.providers.openai", + } + assert configure_calls[0][0][0] == "codex" + assert configure_calls[0][1]["parent_schema"] == "main.models" + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_model_location_configures_without_globally_discovered_models(self, monkeypatch, tool): + state = { + **MINIMAL_STATE, + "claude_models": {}, + "codex_models": [], + "available_tools": [], + } + monkeypatch.setattr(cli_mod, "configure_shared_state", lambda *a, **k: state) + monkeypatch.setattr( + cli_mod, + "check_gateway_endpoint", + lambda *a, **k: pytest.fail("global availability must not gate a model location"), + ) + monkeypatch.setattr(cli_mod, "install_tool_binary", lambda *a, **k: True) + monkeypatch.setattr(cli_mod, "install_databricks_ai_tools_for_agents", lambda *a, **k: None) + configure_calls = [] + monkeypatch.setattr( + cli_mod, + "configure_tool", + lambda *args, **kwargs: configure_calls.append((args, kwargs)) or args[1], + ) + + assert ( + cli_mod.configure_workspace_command( + selected_tools=[tool], + workspaces=[("https://example.databricks.com", None)], + model_location="main.models", + ) + == 0 + ) + + assert configure_calls[0][0][0] == tool + configured_state = configure_calls[0][0][1] + assert configured_state is not state + assert configure_calls[0][1]["parent_schema"] == "main.models" + assert configured_state["model_locations"] == {tool: "main.models"} + assert configured_state["available_tools"] == [tool] + assert "model_locations" not in state + + def test_managed_config_detection_does_not_reset_saved_locations(self, monkeypatch): + state = { + **MINIMAL_STATE, + "model_locations": {"claude": "main.models"}, + } + managed = {"enabled_agents": {}} + monkeypatch.setattr(cli_mod, "configure_shared_state", lambda *a, **k: state) + monkeypatch.setattr(cli_mod, "refresh_managed_config", lambda s: (managed, False)) + monkeypatch.setattr(cli_mod, "_print_managed_summary", lambda *a, **k: None) + + assert ( + cli_mod.configure_workspace_command( + selected_tools=["claude"], + workspaces=[("https://example.databricks.com", None)], + ) + == 0 + ) + + assert state["model_locations"] == {"claude": "main.models"} + + def test_unavailable_selection_does_not_reset_saved_location(self, monkeypatch): + state = { + **MINIMAL_STATE, + "model_locations": {"claude": "main.models"}, + "claude_models": {}, + } + monkeypatch.setattr(cli_mod, "configure_shared_state", lambda *a, **k: state) + monkeypatch.setattr(cli_mod, "check_gateway_endpoint", lambda *a, **k: False) + + with pytest.raises(RuntimeError, match="No coding agents are available"): + cli_mod.configure_workspace_command( + selected_tools=["claude"], + workspaces=[("https://example.databricks.com", None)], + ) + + assert state["model_locations"] == {"claude": "main.models"} + + def test_unrelated_agent_configure_preserves_model_locations(self, monkeypatch): + state = { + **MINIMAL_STATE, + "model_locations": {"claude": "main.models", "codex": "other.models"}, + } + monkeypatch.setattr(cli_mod, "configure_shared_state", lambda *a, **k: state) + monkeypatch.setattr(cli_mod, "check_gateway_endpoint", lambda *a, **k: True) + monkeypatch.setattr(cli_mod, "install_tool_binary", lambda *a, **k: True) + monkeypatch.setattr(cli_mod, "configure_selected_tools", lambda s, tools: s) + + assert ( + cli_mod.configure_workspace_command( + selected_tools=["gemini"], + workspaces=[("https://example.databricks.com", None)], + ) + == 0 + ) + + assert state["model_locations"] == { + "claude": "main.models", + "codex": "other.models", + } + def test_provider_picker_gated_by_interactive_path(self, monkeypatch): import ucode.cli as cli_mod @@ -2833,10 +4089,14 @@ def test_managed_configure_accumulates_available_tools_for_all_agents(self, monk monkeypatch.setattr(cli_mod, "resolve_state", lambda m, s, tool: dict(s)) monkeypatch.setattr(cli_mod, "_print_managed_summary", lambda *a, **k: None) + persisted_states: list[dict] = [] + def fake_configure(s, tools, **kwargs): - # Mirror configure_selected_tools: merge onto a copy, never the caller's dict. + # Mirror configure_selected_tools: merge onto a copy, persist the developer snapshot, + # and never mutate the caller's dict. merged = dict(s) merged["available_tools"] = sorted(set(s.get("available_tools") or []) | set(tools)) + persisted_states.append(cli_mod.developer_state_from_resolved(merged)) return merged monkeypatch.setattr(cli_mod, "configure_selected_tools", fake_configure) @@ -2844,7 +4104,9 @@ def fake_configure(s, tools, **kwargs): monkeypatch.setattr( cli_mod, "_configure_managed_mcp_servers", - lambda m: seen.update(available=list(state.get("available_tools") or [])), + lambda m: seen.update( + available=list(persisted_states[-1].get("available_tools") or []) + ), ) assert cli_mod.configure_workspace_command(workspaces=[("https://w.com", None)]) == 0 @@ -3304,6 +4566,31 @@ def test_happy_path_prints_success_without_model_service_detail(self, monkeypatc assert "Unity Gateway connected" in output assert "Model service:" not in output + def test_persist_false_skips_state_and_cross_workspace_mcp_writes(self, monkeypatch): + cli_mod, _, _, saved = self._stub_deps( + monkeypatch, + pat_token="dapi-pat", + existing_state={"workspace": "https://other.databricks.com"}, + ) + purge_calls: list[tuple[dict, str]] = [] + monkeypatch.setattr( + cli_mod, + "purge_cross_workspace_mcp_residue", + lambda state, workspace: purge_calls.append((state, workspace)), + ) + + state = cli_mod.configure_shared_state( + self.WS, + profile="DEFAULT", + tools=["claude"], + skip_model_discovery=True, + persist=False, + ) + + assert state["workspace"] == self.WS + assert saved == [] + assert purge_calls == [] + @pytest.mark.parametrize( ("responses", "expected_model_service"), [ diff --git a/tests/test_state.py b/tests/test_state.py index 33dd6aba..4e54a6d1 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -13,13 +13,17 @@ build_agent_state, clear_state, get_applied_managed_update_time, + get_model_location, get_provider_service, hydrate_state, load_full_state, load_state, + load_workspace_state, mark_tool_managed, save_state, set_applied_managed_update_time, + set_current_workspace, + set_model_location, set_provider_service, ) @@ -142,6 +146,24 @@ def test_load_state_returns_empty_when_no_workspace(self): result = load_state() assert result == {} + def test_load_workspace_state_preserves_current_workspace_and_preferences(self): + other_workspace = "https://other.databricks.com" + save_state({"workspace": FAKE_WS, "available_tools": ["claude"]}) + save_state( + set_model_location( + {"workspace": other_workspace, "available_tools": ["codex"]}, + "codex", + "main.models", + ) + ) + set_current_workspace(FAKE_WS) + + loaded = load_workspace_state(other_workspace) + + assert loaded["available_tools"] == ["codex"] + assert get_model_location(loaded, "codex") == "main.models" + assert load_full_state()["current_workspace"] == FAKE_WS + # --------------------------------------------------------------------------- # clear_state @@ -186,6 +208,26 @@ def test_clearing_one_tool_keeps_the_other(self): assert get_provider_service(state, "codex") == "main.a.openai" +class TestModelLocation: + def test_get_returns_none_when_unset_or_invalid(self): + assert get_model_location({}, "claude") is None + assert get_model_location({"model_locations": 123}, "claude") is None + + def test_set_and_clear(self): + state = set_model_location({}, "claude", "main.models") + set_model_location(state, "codex", "other.models") + assert get_model_location(state, "claude") == "main.models" + assert get_model_location(state, "codex") == "other.models" + + set_model_location(state, "claude", None) + assert get_model_location(state, "claude") is None + assert get_model_location(state, "codex") == "other.models" + + def test_survives_workspace_roundtrip(self): + save_state(set_model_location({"workspace": FAKE_WS}, "claude", "main.models")) + assert get_model_location(load_state(), "claude") == "main.models" + + class TestAppliedManagedUpdateTime: def test_get_returns_none_when_unset(self): assert get_applied_managed_update_time({}) is None