From a6a5651cc40fc2e2b8952db6fe4aa7a0c088e5c9 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Mon, 26 Oct 2026 12:59:56 +0000 Subject: [PATCH 01/18] copilot: register ug MCP servers in Copilot's user config ug now writes Copilot's MCP servers into Copilot's own user config, ~/.copilot/mcp-config.json (or $COPILOT_HOME/mcp-config.json), the way it writes Claude Code's ~/.claude.json and Codex's ~/.codex/config.toml. The ug-owned side file loaded only through --additional-mcp-config goes away: - Copilot's own `copilot mcp list` and plain `copilot` see ug's servers, and `ug mcp list` reads Copilot's status from `copilot mcp list --json` instead of reporting every server as missing. - Revert removes ug's servers one by one, like the other agents, instead of restoring a side-file backup that could already hold a ug server. - A same-name entry is replaced in place, as for Claude Code and Codex. - Servers in the old side file move into the user config on the next ug write or `ug copilot` launch; revert deletes the side file. - A user config that isn't valid JSON is left alone with an error. - `ug copilot` passes --allow-all-mcp-server-instructions: offline mode empties Copilot's instruction allowlist, so the gateway's servers lost their instructions. Tests: unit coverage for the writers, migration, COPILOT_HOME, revert and the JSON listing; a Copilot MCP integration journey (add, Copilot's listing, a headless tool call, revert); Copilot cases in the managed MCP and skills integration tests with their fixtures and a non-blocking managed Copilot lane. tests/AGENTS.md now admits Copilot MCP/skills journeys matching the Claude/Codex ones. Co-authored-by: Isaac --- .github/workflows/integration.yml | 57 +++++++++ README.md | 2 +- src/ucode/agents/copilot.py | 120 +++++++++++++----- src/ucode/mcp.py | 67 ++++++---- tests/AGENTS.md | 7 +- tests/README.md | 2 + tests/conftest.py | 12 ++ .../fixtures/managed_config/copilot_mcp.json | 24 ++++ .../copilot_skills_by_name.json | 24 ++++ tests/integration/README.md | 21 ++- .../test_ug_configure_managed_mcp.py | 28 +++- .../test_ug_configure_managed_skills.py | 27 +++- tests/integration/test_ug_copilot_mcp.py | 94 ++++++++++++++ tests/integration/utils/agents/copilot.py | 32 ++++- tests/integration/utils/evidence.py | 9 ++ tests/test_agent_copilot.py | 71 +++++++++-- tests/test_integration_contract.py | 1 + tests/test_mcp.py | 65 +++++++--- 18 files changed, 555 insertions(+), 108 deletions(-) create mode 100644 tests/fixtures/managed_config/copilot_mcp.json create mode 100644 tests/fixtures/managed_config/copilot_skills_by_name.json create mode 100644 tests/integration/test_ug_copilot_mcp.py diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 519c3be28..d51aaca4d 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -486,6 +486,63 @@ jobs: ${{ runner.temp }}/ug-integration/artifacts/ ${{ runner.temp }}/ug-integration/wheels/ + # Non-blocking like the Copilot lane: continue-on-error and absent from the cujs `needs`. + # Runs the Copilot managed_fixture cases on the managed e2e workspace with injected configs that + # enable Copilot (a service principal authenticates it, like the `managed` lane). + managed-copilot: + name: Managed config · Copilot (non-blocking) + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + continue-on-error: true + runs-on: ubuntu-22.04 + timeout-minutes: 30 + env: + UCODE_TEST_WORKSPACE: ${{ secrets.E2E_ADMIN_WORKSPACE }} + DATABRICKS_CLIENT_ID: ${{ secrets.E2E_ADMIN_SP_CLIENT_ID }} + DATABRICKS_CLIENT_SECRET: ${{ secrets.E2E_ADMIN_SP_CLIENT_SECRET }} + DEPENDENCY: ${{ inputs.dependency }} + TEST_MARKER: managed_fixture and copilot + ARTIFACT_NAME: integration-managed-copilot + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ inputs.ref || github.sha }} + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22.19.0 + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: 0.9.8 + enable-cache: ${{ inputs.ref == '' }} + - uses: databricks/setup-cli@bdb89f81c11a5bd647fd55b585b7c396ec68a25a # v1.0.0 + with: + version: 1.17.0 + - name: Run the Copilot managed cases against the managed workspace + shell: bash + run: | + args=(--copilot-version "$COPILOT_VERSION") + if [[ -n "$DEPENDENCY" ]]; then + args+=(--dependency "$DEPENDENCY") + fi + uv run --no-project --python 3.12 python scripts/run_integration.py \ + --python 3.12 --ug-version "$UG_VERSION" --entry-point "$ENTRY_POINT" \ + --default-index "$PACKAGE_INDEX" --output "$RUNNER_TEMP/ug-integration" \ + "${args[@]}" -- -m "$TEST_MARKER" + - name: Upload managed Copilot evidence + if: ${{ !cancelled() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ env.ARTIFACT_NAME }} + include-hidden-files: true + path: | + ${{ runner.temp }}/ug-integration/*.json + ${{ runner.temp }}/ug-integration/*.txt + ${{ runner.temp }}/ug-integration/*.xml + ${{ runner.temp }}/ug-integration/*.log + ${{ runner.temp }}/ug-integration/artifacts/ + ${{ runner.temp }}/ug-integration/wheels/ + # managed and dedicated-cuj stay Linux-only: ug has no Windows managed-settings # writer, and the CUJ suite drives agents through a POSIX PTY. managed: diff --git a/README.md b/README.md index 7bc728e19..85244c3ba 100644 --- a/README.md +++ b/README.md @@ -580,7 +580,7 @@ preferences are ignored and their files are left untouched. See the bundled | Claude Code | `~/.claude/ucode-settings.json`, `~/.claude.json`, `/etc/claude-code/managed-settings.json` (Linux), `/Library/Application Support/ClaudeCode/managed-settings.json` (macOS), `C:\Program Files\ClaudeCode\managed-settings.json` (Windows, written only from an elevated terminal) | | Gemini CLI | `~/.gemini/ucode.env`, `~/.ucode/.gemini-home/.gemini/settings.json` | | OpenCode | `~/.ucode/opencode-xdg/opencode/opencode.json`, `~/.ucode/opencode-xdg/opencode/plugin/ucode-auth.js` | -| GitHub Copilot CLI | `~/.copilot/ucode.env`, `~/.copilot/ucode-mcp-config.json` | +| GitHub Copilot CLI | `~/.copilot/ucode.env`, `~/.copilot/mcp-config.json` (or `$COPILOT_HOME/mcp-config.json`) | | Pi | `~/.ucode/pi-home/.pi/agent/models.json`, `~/.ucode/pi-home/.pi/agent/settings.json` | | Antigravity CLI | `~/.gemini/config/skills.json` (adds `~/.agents/skills`), `~/.gemini/config/mcp_config.json` (ug's MCP servers only); the gateway is set per launch through `AGY_LLM_GATEWAY_*` variables | | Cursor Agent | `~/.cursor/mcp.json` | diff --git a/src/ucode/agents/copilot.py b/src/ucode/agents/copilot.py index ed979b368..34f8c6139 100644 --- a/src/ucode/agents/copilot.py +++ b/src/ucode/agents/copilot.py @@ -41,8 +41,8 @@ from ucode.config_io import ( APP_DIR, ToolSpec, - apply_json_mcp_diff, backup_existing_file, + is_dry_run, parse_dotenv, read_json_safe, write_dotenv, @@ -88,9 +88,11 @@ COPILOT_CONFIG_DIR = Path.home() / ".copilot" COPILOT_ENV_PATH = COPILOT_CONFIG_DIR / "ucode.env" -COPILOT_MCP_CONFIG_PATH = COPILOT_CONFIG_DIR / "ucode-mcp-config.json" +COPILOT_MCP_CONFIG_PATH = COPILOT_CONFIG_DIR / "mcp-config.json" COPILOT_BACKUP_PATH = APP_DIR / "copilot-ucode-env.backup" -COPILOT_MCP_BACKUP_PATH = APP_DIR / "copilot-ucode-mcp-config.backup.json" +# Older ug versions kept their servers in a side file loaded with `--additional-mcp-config`. +LEGACY_MCP_CONFIG_PATH = COPILOT_CONFIG_DIR / "ucode-mcp-config.json" +LEGACY_MCP_BACKUP_PATH = APP_DIR / "copilot-ucode-mcp-config.backup.json" COPILOT_PROXY_LOG_PATH = APP_DIR / "logs" / "copilot-proxy.log" _REGISTRY_PREFIX = "ug-providers-" _REGISTRY_LOCK_NAME = "ug-providers.lock" @@ -800,35 +802,88 @@ def build_mcp_server_entry(argv: list[str]) -> dict: } -def write_mcp_server_config(name: str, argv: list[str]) -> bool: - backup_existing_file(COPILOT_MCP_CONFIG_PATH, COPILOT_MCP_BACKUP_PATH) - existing = read_json_safe(COPILOT_MCP_CONFIG_PATH) - mcp_servers = existing.get("mcpServers") - if not isinstance(mcp_servers, dict): - mcp_servers = {} - removed = name in mcp_servers - mcp_servers[name] = build_mcp_server_entry(argv) - existing["mcpServers"] = mcp_servers - write_json_file(COPILOT_MCP_CONFIG_PATH, existing) +def copilot_mcp_config_path() -> Path: + """The user config Copilot reads ``mcpServers`` from (and ``copilot mcp add`` writes): + ``$COPILOT_HOME/mcp-config.json`` when that env var is set, else ``~/.copilot/mcp-config.json``.""" + copilot_home = os.environ.get("COPILOT_HOME") + return Path(copilot_home) / "mcp-config.json" if copilot_home else COPILOT_MCP_CONFIG_PATH + + +def _read_mcp_config_for_rewrite(path: Path) -> dict: + """``path`` parsed for a rewrite (``{}`` when absent). Raises rather than return ``{}`` for a + file that isn't a JSON object, since rewriting it would drop the developer's own servers.""" + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + data = None + if not isinstance(data, dict): + raise RuntimeError( + f"Can't update {path}: it isn't a valid JSON object. Fix it, then retry." + ) + return data + + +def remove_legacy_mcp_config() -> bool: + """Delete the legacy side file and its backup (on revert: everything in them is ug's).""" + existed = LEGACY_MCP_CONFIG_PATH.exists() or LEGACY_MCP_BACKUP_PATH.exists() + if is_dry_run(): + return existed + LEGACY_MCP_CONFIG_PATH.unlink(missing_ok=True) + LEGACY_MCP_BACKUP_PATH.unlink(missing_ok=True) + return existed + + +def _take_legacy_mcp_servers() -> dict[str, dict]: + """The servers in the legacy side file, deleting it and its backup. Every entry there was + written by ug, so it moves into the user config as-is.""" + servers = read_json_safe(LEGACY_MCP_CONFIG_PATH).get("mcpServers") + remove_legacy_mcp_config() + if not isinstance(servers, dict): + return {} + return {name: entry for name, entry in servers.items() if isinstance(entry, dict)} + + +def _apply_mcp_diff(add: dict[str, dict], remove: set[str]) -> set[str]: + path = copilot_mcp_config_path() + config = _read_mcp_config_for_rewrite(path) + servers = config.get("mcpServers") + if not isinstance(servers, dict): + servers = {} + legacy = _take_legacy_mcp_servers() + removed = {name for name in remove if name in servers or name in legacy} + if not add and not removed and not legacy: + return removed + servers.update({name: entry for name, entry in legacy.items() if name not in remove}) + for name in remove: + servers.pop(name, None) + servers.update(add) + config["mcpServers"] = servers + write_json_file(path, config) return removed +def migrate_legacy_mcp_config() -> None: + """Move the servers in the legacy side file into the user config, once.""" + if LEGACY_MCP_CONFIG_PATH.exists() or LEGACY_MCP_BACKUP_PATH.exists(): + _apply_mcp_diff({}, set()) + + +def write_mcp_server_config(name: str, argv: list[str]) -> bool: + """Add (or replace) a stdio (`ug mcp-proxy`) MCP server in Copilot's user config. Returns + True when an entry of that name was replaced.""" + return bool(_apply_mcp_diff({name: build_mcp_server_entry(argv)}, {name})) + + def remove_mcp_server_config(name: str) -> bool: - existing = read_json_safe(COPILOT_MCP_CONFIG_PATH) - mcp_servers = existing.get("mcpServers") - if not isinstance(mcp_servers, dict) or name not in mcp_servers: - return False - mcp_servers.pop(name) - existing["mcpServers"] = mcp_servers - write_json_file(COPILOT_MCP_CONFIG_PATH, existing) - return True + return bool(_apply_mcp_diff({}, {name})) def write_user_mcp_servers(add: dict[str, dict], remove: set[str]) -> set[str]: - """Apply ``add``/``remove`` to Copilot's `mcpServers` in a single read-modify-write. Returns the names actually removed.""" - return apply_json_mcp_diff( - COPILOT_MCP_CONFIG_PATH, "mcpServers", add, remove, backup_path=COPILOT_MCP_BACKUP_PATH - ) + """Apply ``add``/``remove`` to Copilot's user-config ``mcpServers`` in a single + read-modify-write. Returns the names actually removed.""" + return _apply_mcp_diff(add, remove) def scrub_stored_credentials() -> None: @@ -951,6 +1006,10 @@ def launch(state: dict, tool_args: list[str], *, options: LaunchOptions) -> None managed_list=not (provider_service or model_location), ) scrub_stored_credentials() + try: + migrate_legacy_mcp_config() + except RuntimeError as exc: + print_warning(f"ug's MCP servers aren't available in this session: {exc}") # Only Copilot launches need the proxy (and its `httpx` import). from ucode import gateway_proxy @@ -986,7 +1045,7 @@ def launch(state: dict, tool_args: list[str], *, options: LaunchOptions) -> None with session_registry(config) as registry_path: env = build_runtime_env(registry_path, start_model) proc = subprocess_cross_os.popen( - [SPEC["binary"], *mcp_config_args(), *tool_args], env=env + [SPEC["binary"], *MCP_LAUNCH_ARGS, *tool_args], env=env ) returncode = _wait_for_exit(proc) finally: @@ -1000,14 +1059,13 @@ def launch(state: dict, tool_args: list[str], *, options: LaunchOptions) -> None def validate_cmd(binary: str) -> list[str]: return [ binary, - *mcp_config_args(), + *MCP_LAUNCH_ARGS, "--prompt", "say hi in 5 words or less", "--allow-all-tools", ] -def mcp_config_args() -> list[str]: - if not COPILOT_MCP_CONFIG_PATH.exists(): - return [] - return ["--additional-mcp-config", f"@{COPILOT_MCP_CONFIG_PATH}"] +# Offline mode empties Copilot's allowlist of servers whose `instructions` reach the model, so +# without this the gateway's servers lose theirs. +MCP_LAUNCH_ARGS = ("--allow-all-mcp-server-instructions",) diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index 0662616fe..596a97c7c 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os import re import shutil @@ -17,7 +18,7 @@ from rich.table import Table from ucode.agents import antigravity, claude, codex, copilot, cursor, gemini, kilo, opencode -from ucode.config_io import read_json_safe, read_toml_safe, restore_file +from ucode.config_io import read_json_safe, read_toml_safe from ucode.constants import MCP_CLEANUP_SCOPES, MCP_USER_SCOPE from ucode.databricks import ( AIGW_MCP_SERVICES_SEGMENT, @@ -112,7 +113,7 @@ "copilot": { "binary": "copilot", "display": "GitHub Copilot CLI", - "list_command": "copilot mcp list", + "list_command": "copilot mcp list --json", }, "cursor": { "binary": "cursor-agent", @@ -497,6 +498,8 @@ def revert_mcp_configs(state: dict) -> dict[str, bool]: all_servers = list(state.get("mcp_servers") or []) + list( state.get("managed_mcp_servers") or [] ) + if copilot.remove_legacy_mcp_config(): + results["copilot"] = True for server in all_servers: name = server.get("name") if not isinstance(name, str) or not name: @@ -507,16 +510,6 @@ def revert_mcp_configs(state: dict) -> dict[str, bool]: removed_scopes = remove_client_mcp_server(client, name) results[client] = bool(removed_scopes) or results.get(client, False) - # OpenCode MCP entries live in the normal OpenCode config and are restored - # by the main agent config revert. Copilot stores MCP servers separately, - # so restore its original MCP file after removing per-server entries above. - results["copilot"] = restore_file( - copilot.COPILOT_MCP_CONFIG_PATH, - copilot.COPILOT_MCP_BACKUP_PATH, - any( - "copilot" in (server.get("clients") or []) for server in state.get("mcp_servers") or [] - ), - ) or results.get("copilot", False) return results @@ -2457,10 +2450,11 @@ def remove_mcp_command(agents: set[str] | None = None) -> int: # `ug mcp list`: configured MCP servers + their live per-agent connection status # --------------------------------------------------------------------------- -# Per-(server, agent) states surfaced by `ug mcp list`. Agent listings differ: some -# health-check servers, Codex reports enabled/disabled, and Copilot's ug-added entries -# are visible only in the additional config file. NOT_REGISTERED means the agent is -# installed but neither its listing nor ug's additional config contains the server. +# Per-(server, agent) live states surfaced by `ug mcp list`. Every agent's `mcp list` +# health-checks its servers and reports connected/failed, except Codex, Antigravity and +# Copilot, whose listings report only whether an entry is enabled/disabled (no probe). +# NOT_REGISTERED means the agent is installed but doesn't list the server; a server maps to +# None (rendered "agent not installed") when the agent binary isn't installed at all. LIVE_CONNECTED = "connected" LIVE_FAILED = "failed" LIVE_ENABLED = "enabled" @@ -2600,6 +2594,24 @@ def _parse_tree_mcp_list(output: str) -> dict[str, str]: return statuses +def _parse_copilot_mcp_list(output: str) -> dict[str, str]: + """Parse `copilot mcp list --json` into ``{server_name: state}``. + + Like Codex, Copilot reports config state (``enabled``), not a health probe. Output that isn't + the expected JSON object contributes nothing.""" + try: + servers = json.loads(output[output.find("{") :]).get("mcpServers") + except (json.JSONDecodeError, AttributeError): + return {} + if not isinstance(servers, dict): + return {} + return { + name: LIVE_ENABLED if entry.get("enabled", True) else LIVE_DISABLED + for name, entry in servers.items() + if isinstance(entry, dict) + } + + def parse_mcp_list_output(client: str, output: str) -> dict[str, str]: """Parse an agent's `mcp list` output into ``{server_name: live-state}`` (best-effort). @@ -2611,6 +2623,8 @@ def parse_mcp_list_output(client: str, output: str) -> dict[str, str]: if client in ("codex", "antigravity"): # Both print a columnar table with an enabled/disabled state column. parsed = _parse_codex_mcp_list(output) + elif client == "copilot": + parsed = _parse_copilot_mcp_list(output) elif client in ("kilo", "opencode"): parsed = _parse_tree_mcp_list(output) else: @@ -2679,14 +2693,6 @@ def query_live_mcp_status(client: str) -> dict[str, str]: statuses.setdefault(name, state) for name in managed: statuses.setdefault(name, LIVE_UNKNOWN) - if client == "copilot": - # Copilot reads ug's additional MCP config only on an agent launch. Its bare `mcp list` - # omits these servers, so report their configured state without claiming a live probe. - servers = read_json_safe(copilot.COPILOT_MCP_CONFIG_PATH).get("mcpServers") - if isinstance(servers, dict): - for name, entry in servers.items(): - if isinstance(name, str) and isinstance(entry, dict): - statuses.setdefault(name, LIVE_ENABLED) return statuses @@ -2921,10 +2927,15 @@ def list_mcp_command(agents: set[str] | None = None) -> int: if config_only else "Status is from each agent's `mcp list`" ) - if "codex" in probe_clients and "codex" not in config_only: - live_note += "; Codex reports enabled/disabled" - if "copilot" in probe_clients: - live_note += "; ug-added Copilot servers report configured/enabled, not live health" + # Only mention the enabled/disabled caveat for the agents actually probed through their CLI. + config_state_clients = [ + MCP_CLIENTS[client]["display"] + for client in ("codex", "copilot") + if client in probe_clients and client not in config_only + ] + if config_state_clients: + verb = "reports" if len(config_state_clients) == 1 else "report" + live_note += f"; {' and '.join(config_state_clients)} {verb} enabled/disabled" print_note(f"{live_note}.") # A `needs sign-in` server is registered fine but needs the one-time per-user connection login; # point the developer at it rather than leaving an opaque status. diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 0093c805f..e2fbe07da 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -93,9 +93,10 @@ exercise. and run documentation at the suite root. - Current scope is basic Claude/Codex configuration, routing, script usage, command forwarding, and configure/revert CUJs, plus Copilot CLI headless - model tasks and one TUI model-switch journey (non-blocking lane), plus Antigravity CLI - configure, TUI, headless, External Models, MCP-tool, skill-directory, and revert journeys - (blocking Linux lane). Do not add other MCP/skills functionality, + model tasks, one TUI model-switch journey, and Copilot MCP/skills journeys matching the + Claude/Codex ones (non-blocking lanes), plus Antigravity CLI configure, TUI, headless, + External Models, MCP-tool, skill-directory, and revert journeys (blocking Linux lane). + Do not add other MCP/skills functionality, tracing, or the broad configure-option matrix without a new scope request. Existing `mcp --help` checks cover dispatch only. diff --git a/tests/README.md b/tests/README.md index d3196849d..747bb73de 100644 --- a/tests/README.md +++ b/tests/README.md @@ -752,6 +752,8 @@ integration utilities; only CUJ-specific evidence correlation stays in a test fi | `test_ug_copilot_tui_switches_from_claude_to_gpt_with_model_command` | TUI: answer folder trust, Claude task, `/model dbx-responses/`, GPT task, `/exit` | Both answers sent as the Claude then GPT model; the session records the switch; normal exit; no session registry left (non-blocking CI lane) | | `test_ug_copilot_refreshes_a_revoked_token_mid_session`, `test_ug_copilot_reports_expired_sign_in` | Sign in through `DATABRICKS_BEARER_COMMAND` minting short-lived PATs; mid-task the agent revokes them (or also signs out) | Revoked: a token is minted after the revocation and the task completes without restarting Copilot. Signed out: output shows "Databricks sign-in expired" with `databricks auth login`; the failure is in `~/.ucode/logs/copilot-proxy.log`, not the terminal (non-blocking CI lane) | | `test_ug_configure_copilot_repeat_and_revert` | Configure Copilot twice over a user-owned `~/.copilot/mcp-config.json`, run a GPT task, revert twice | Task completes; no file holds the bearer; revert removes `~/.copilot/ucode.env`, leaves the user file byte-identical and reports Not Configured. (non-blocking CI lane) | +| `test_ug_mcp_add_copilot_registers_server_in_copilot_config` | Over a developer-owned, disabled server in `~/.copilot/mcp-config.json`, configure Copilot, `ug mcp add --agents copilot --names system.ai.web_search`, run a headless task that calls the tool, then revert | `copilot mcp list --json` lists ug's server from the user config, enabled, next to the developer's still-disabled one; `ug mcp list --agents copilot` reports it enabled; Copilot's session events record a successful call loaded from the user config; revert leaves only the developer's server and no legacy `ucode-mcp-config.json` (non-blocking CI lane) | +| `test_managed_fixture_copilot_mcp_registered_in_user_config`, `test_managed_fixture_copilot_skills_lists_downloaded_skill` | Non-interactive configure with an injected config enabling Copilot with a managed MCP server or a managed skill | `copilot mcp list --json` lists the managed server from the user config as the ug mcp-proxy command; `copilot skill list --json` lists the downloaded skill as an enabled personal skill (non-blocking managed Copilot lane) | | `test_ug_copilot_upgrade_scrubs_token_saved_by_older_ug` | Configure Copilot with ug v0.2.0 (pinned commit; saves the bearer in `~/.copilot/ucode.env`), then configure with this ug, run a GPT task, revert | v0.2.0 left the bearer; after this ug's configure no file holds it; task completes; revert leaves no token and no `ucode.env` (non-blocking CI lane) | | `test_ug_opencode_headless_prompt_argument` | Run OpenCode from a script (`run --format json --auto`) with an argument prompt | Completed Read tool call; final text answer contains the file value; exit zero (non-blocking CI lane) | | `test_ug_agents_self_managed_opencode_journey` | Under the injected `managed_workspace_default` config (enables Claude/Codex, not OpenCode): bare configure, `ug agents list`, refused OpenCode launch, `ug agents add opencode`, real headless task, `ug agents remove opencode` (marker `managed_fixture and opencode`, non-blocking CI lane) | OpenCode absent from the list and launch fails with "doesn't enable OpenCode" / `ug agents add opencode`; after add it is listed self-managed and the headless Read task returns the fixture value; after remove it is hidden and refused again | diff --git a/tests/conftest.py b/tests/conftest.py index bf98869ac..5bab80a48 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,6 +51,7 @@ def _isolate_ucode_state(tmp_path, monkeypatch): from ucode.agents import antigravity as antigravity_mod from ucode.agents import claude as claude_mod from ucode.agents import codex as codex_mod + from ucode.agents import copilot as copilot_mod from ucode.agents import vscode as vscode_mod from ucode.agents.desktop import claude as desktop_mod @@ -126,6 +127,17 @@ def install_test_policy(path, desired_text, *, interactive=False, allow_sudo=Tru codex_mod, "CODEX_MODEL_CATALOG_PATH", state_dir / "codex-model-catalog.json" ) monkeypatch.setattr(codex_mod, "CODEX_CONFIG_PATH", tmp_path / ".codex" / "ucode.config.toml") + # MCP writes and revert edit Copilot's user config and delete the legacy side file. + monkeypatch.setattr( + copilot_mod, "COPILOT_MCP_CONFIG_PATH", tmp_path / ".copilot" / "mcp-config.json" + ) + monkeypatch.setattr( + copilot_mod, "LEGACY_MCP_CONFIG_PATH", tmp_path / ".copilot" / "ucode-mcp-config.json" + ) + monkeypatch.setattr( + copilot_mod, "LEGACY_MCP_BACKUP_PATH", state_dir / "copilot-ucode-mcp-config.backup.json" + ) + monkeypatch.delenv("COPILOT_HOME", raising=False) def reject_privileged_write(path, _desired_text): pytest.fail( diff --git a/tests/fixtures/managed_config/copilot_mcp.json b/tests/fixtures/managed_config/copilot_mcp.json new file mode 100644 index 000000000..8cec6a864 --- /dev/null +++ b/tests/fixtures/managed_config/copilot_mcp.json @@ -0,0 +1,24 @@ +{ + "spec_version": 1, + "default_agent": "CODING_AGENT_COPILOT", + "enabled_agents": [ + { + "agent": "CODING_AGENT_COPILOT", + "config": { + "models": { + "model_services": [ + "system.ai.claude-sonnet-4-6" + ] + }, + "default_models": { + "default_model": "system.ai.claude-sonnet-4-6" + } + } + } + ], + "mcp_servers": { + "names": [ + "system.ai.github" + ] + } +} diff --git a/tests/fixtures/managed_config/copilot_skills_by_name.json b/tests/fixtures/managed_config/copilot_skills_by_name.json new file mode 100644 index 000000000..0d6c2d02b --- /dev/null +++ b/tests/fixtures/managed_config/copilot_skills_by_name.json @@ -0,0 +1,24 @@ +{ + "spec_version": 1, + "default_agent": "CODING_AGENT_COPILOT", + "enabled_agents": [ + { + "agent": "CODING_AGENT_COPILOT", + "config": { + "models": { + "model_services": [ + "system.ai.claude-sonnet-4-6" + ] + }, + "default_models": { + "default_model": "system.ai.claude-sonnet-4-6" + } + } + } + ], + "skills": { + "names": [ + "main.default.forkable-meals" + ] + } +} diff --git a/tests/integration/README.md b/tests/integration/README.md index 17a350c59..273e92c7c 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -364,6 +364,12 @@ model-discovery schema and completes a file task on its Claude-compatible model `test_ug_copilot_providers.py` runs Copilot through the MPS fixtures below: a file task on the Anthropic service's model, on the OpenAI, Azure OpenAI and Microsoft Foundry services' `gpt-5-nano`, and on a named model of the Bedrock allow-all service; launching on the relayed service fails before Copilot starts. +`test_ug_copilot_mcp.py` adds `system.ai.web_search` with `ug mcp add --agents copilot` over a +developer's own disabled server, checks Copilot's `copilot mcp list --json` and `ug mcp list`, runs +a headless task whose tool call Copilot's session events record as successful, and reverts. The +Copilot cases in `test_ug_configure_managed_mcp.py` and `test_ug_configure_managed_skills.py` check +that a managed server and a managed skill reach Copilot (`copilot mcp list --json`, `copilot skill +list --json`). Antigravity CLI has five live headless journeys in `test_ug_antigravity_headless.py` and three in `test_ug_configure_antigravity.py`. Headless runs use `agy -p --output-format stream-json`; the @@ -691,6 +697,7 @@ test_ug_copilot_lifecycle.py # Copilot refresh after revocation, sign test_ug_copilot_models.py # Copilot model registry: Claude then GPT, unknown model, TUI /model switch test_ug_copilot_providers.py # Copilot via Model Provider Services: Anthropic, OpenAI, Azure OpenAI, Foundry, Bedrock allow-all; relayed refused test_ug_copilot_model_location.py # Copilot on a Unity Catalog model location: fresh --model-location file task +test_ug_copilot_mcp.py # Copilot MCP: ug mcp add, tool call, ug mcp list, revert keeps the user's server test_ug_antigravity_headless.py # Antigravity via ug's proxy: file task, --model (ug agy), Gemini MPS targets, MCP SQL tool, skill dir test_ug_configure_antigravity.py # Antigravity: configure picker + TUI task + reopen; External Models picker; revert keeps user files test_ug_codex_tracing.py # Codex OTLP export reaches the configured trace table @@ -710,8 +717,8 @@ test_ug_claude_model_discovery.py # unmanaged scenarios 9, 11, 13 test_ug_codex_model_discovery.py # unmanaged scenarios 10, 12, 14 test_ug_configure_managed.py # stubbed static model list/catalog pointer, no agent selector, saved-config replay test_ug_configure_managed_models.py # stubbed model sources, smart-routing banner, Codex fallback metadata -test_ug_configure_managed_mcp.py # stubbed managed MCP list -test_ug_configure_managed_skills.py # stubbed managed skills: download, coexist, reconcile away +test_ug_configure_managed_mcp.py # stubbed managed MCP list (Claude, Codex, Copilot) +test_ug_configure_managed_skills.py # stubbed managed skills: download, coexist, reconcile away (Claude, Codex, Copilot) test_ug_configure_managed_lifecycle.py # none -> A -> B -> MPS -> none: reconcile, clear on MPS/no-config test_installation.py # fresh installed package utils/ # process/terminal/evidence helpers and Docker files @@ -919,9 +926,9 @@ cases instead complete real file tasks through `--workspace` and `--model-locati journeys check setup and launch with existing services, without inference. There are **88 live cases** (including 12 marked TUI journeys) and **7 installation -checks** with Claude and Codex; selecting OpenCode adds one live headless case and two `managed_fixture` cases; selecting Copilot adds seventeen live cases; selecting Antigravity adds eight live cases (one TUI). One +checks** with Claude and Codex; selecting OpenCode adds one live headless case and two `managed_fixture` cases; selecting Copilot adds eighteen live cases and two `managed_fixture` cases; selecting Antigravity adds eight live cases (one TUI). One **`workspace_switch` case** uses two real workspaces and checks skills MCP cleanup and a completed -Claude task. A further **44 `managed_fixture` cases** (twelve of them also `live`) run on the +Claude task. A further **46 `managed_fixture` cases** (twelve of them also `live`) run on the managed workspace with a checked-in JSON CodingAgentConfig from `tests/fixtures/managed_config/` injected through `UCODE_MANAGED_CONFIG_STUB`; there are no cases that read a published config. Twelve explicit configured/fresh Claude and Codex discovery and source-override journeys use the @@ -1168,8 +1175,10 @@ A second non-blocking job, **Managed OpenCode self-managed** (`managed_fixture a in `test_ug_agents_self_managed.py`), installs Claude, Codex and OpenCode and runs against the managed e2e workspace (`E2E_ADMIN_WORKSPACE`) with the injected `managed_workspace_default` config, which enables Claude/Codex but not OpenCode. It is likewise outside `cujs`. -A non-blocking **Copilot** job (`live and copilot`, sixteen cases) runs on Linux and Windows the -same way and is likewise outside `cujs` until it is stable. +A non-blocking **Copilot** job (`live and copilot`, seventeen cases) runs on Linux and Windows the +same way and is likewise outside `cujs` until it is stable. A non-blocking **Managed config · Copilot** +job (`managed_fixture and copilot`, two cases) installs only Copilot and runs on the managed e2e +workspace with injected configs that enable Copilot; it is likewise outside `cujs`. A blocking **Antigravity** job (`live and antigravity`, eight cases) runs on Linux only with the pinned `ANTIGRAVITY_BUILD`; it is part of the `cujs` gate. diff --git a/tests/integration/test_ug_configure_managed_mcp.py b/tests/integration/test_ug_configure_managed_mcp.py index bd158cae2..2c94f5019 100644 --- a/tests/integration/test_ug_configure_managed_mcp.py +++ b/tests/integration/test_ug_configure_managed_mcp.py @@ -10,9 +10,11 @@ the developer's own ~/.codex/config.toml servers. A non-interactive configure has no sudo reconcile, so it falls back to the user-scope registration, which the real /mcp view still lists. Claude's `managedMcpServers` path is covered by unit tests; its integration coverage needs the managed -workspace to publish the `claude-code` OAuth client, which E2E_ADMIN_WORKSPACE does not. +workspace to publish the `claude-code` OAuth client, which E2E_ADMIN_WORKSPACE does not. Copilot +has no OS-managed MCP file, so its managed servers land in its user config, ~/.copilot/mcp-config.json. """ +import json import tomllib import pytest @@ -114,3 +116,27 @@ def test_managed_fixture_codex_http_headers_in_managed_file(live_session, worksp f"Expected {managed_header_key!r} = {managed_header_value!r}, " f"got {headers[managed_header_key]!r}" ) + + +@pytest.mark.managed_fixture +@pytest.mark.copilot +def test_managed_fixture_copilot_mcp_registered_in_user_config(live_session, workspace): + """Scenario: a non-interactive configure with a managed MCP server enabled for Copilot. + + Expected: Copilot's own `copilot mcp list --json` lists the server from its user config, + enabled, as the ug mcp-proxy stdio command for the server's mcp-services URL. + """ + session = live_session + use_managed_config_fixture(session, "copilot_mcp") + result = session.run("configure", "--workspace", workspace, "--skip-upgrade", timeout=240) + assert "Select coding agents to configure:" not in result.stdout, result.stdout + + listing = session.run("mcp", "list", "--json", binary=session.which("copilot"), timeout=60) + servers = json.loads(listing.stdout)["mcpServers"] + entries = {name: entry for name, entry in servers.items() if "github" in name.lower()} + assert entries, servers + entry = next(iter(entries.values())) + assert entry["source"] == "user", entry + assert entry["enabled"] is True, entry + assert entry["command"].endswith("ug"), entry + assert "mcp-services" in entry["args"][entry["args"].index("--url") + 1], entry diff --git a/tests/integration/test_ug_configure_managed_skills.py b/tests/integration/test_ug_configure_managed_skills.py index 9a96eda57..f650c1e4d 100644 --- a/tests/integration/test_ug_configure_managed_skills.py +++ b/tests/integration/test_ug_configure_managed_skills.py @@ -3,12 +3,14 @@ The admin CodingAgentConfig is a checked-in JSON fixture injected via UCODE_MANAGED_CONFIG_STUB so the real /skills TUI can be driven against a managed skill; only the config INPUT is stubbed (auth, the skill download, the config writers, and the agent binary stay real). These assert what the agent presents, not the bundles on disk (that is unit tests' job). See -tests/AGENTS.md rule 4. Claude reads ~/.claude/skills and Codex reads the shared ~/.agents/skills, -both of which `ug configure` writes, so a managed skill reaches either agent. The reconcile +tests/AGENTS.md rule 4. Claude reads ~/.claude/skills and Codex and Copilot read the shared +~/.agents/skills, both of which `ug configure` writes, so a managed skill reaches each agent. The reconcile lifecycle test additionally checks the on-disk bundles, since removal-on-reconcile is a disk change that has no steady-state `/skills` signal. """ +import json + import pytest from utils.managed import use_managed_config_fixture from utils.terminal import AgentTerminal @@ -79,6 +81,27 @@ def test_managed_fixture_codex_skills_lists_downloaded_skill(live_session, works ) +@pytest.mark.managed_fixture +@pytest.mark.copilot +def test_managed_fixture_copilot_skills_lists_downloaded_skill(live_session, workspace): + """Scenario: configure Copilot under an injected config that names a managed skill, then list + Copilot's skills. + + Expected: Copilot's own `copilot skill list --json` lists the downloaded managed skill as an + enabled personal skill, read from the shared ~/.agents/skills dir `ug configure` writes. + """ + session = live_session + use_managed_config_fixture(session, "copilot_skills_by_name") + result = session.run("configure", "--workspace", workspace, "--skip-upgrade", timeout=240) + assert "Select coding agents to configure:" not in result.stdout, result.stdout + + listing = session.run("skill", "list", "--json", binary=session.which("copilot"), timeout=60) + skills = [skill for skill in json.loads(listing.stdout) if skill.get("name") == SKILL_NAME] + assert skills, listing.stdout + assert all(skill["enabled"] is True for skill in skills), skills + assert any(str(skill["source"]).startswith("personal") for skill in skills), skills + + @pytest.mark.managed_fixture @pytest.mark.claude def test_managed_skills_reconcile_lifecycle(live_session, workspace): diff --git a/tests/integration/test_ug_copilot_mcp.py b/tests/integration/test_ug_copilot_mcp.py new file mode 100644 index 000000000..3dd82656a --- /dev/null +++ b/tests/integration/test_ug_copilot_mcp.py @@ -0,0 +1,94 @@ +"""CUJ for Databricks MCP servers in GitHub Copilot CLI through installed ug.""" + +import json + +import pytest +from utils.constants import CLAUDE_TEST_MODEL, COPILOT_HEADLESS_FLAGS +from utils.evidence import copilot_mcp_tool_calls +from utils.terminal import TerminalProcess + +pytestmark = [pytest.mark.live, pytest.mark.copilot] + +MCP_SERVICE = "system.ai.web_search" +# The developer's own server, disabled in Copilot's settings so the session never starts it. +USER_SERVER = "developer-own" +USER_ENTRY = {"type": "local", "command": "developer-own-mcp", "args": [], "tools": ["*"]} + + +def _copilot_mcp_servers(session) -> dict: + """Copilot's own view of its configured MCP servers (`copilot mcp list --json`).""" + result = session.run("mcp", "list", "--json", binary=session.which("copilot"), timeout=60) + return json.loads(result.stdout)["mcpServers"] + + +def test_ug_mcp_add_copilot_registers_server_in_copilot_config(live_session, workspace): + """Scenario: with a disabled server of their own in Copilot's user config, the developer + configures Copilot, adds `system.ai.web_search` with `ug mcp add --agents copilot`, asks + `ug copilot` to use it in a headless task, then runs `ug revert`. + + Expected: Copilot's own `copilot mcp list --json` lists ug's server from its user config + next to the developer's still-disabled server; `ug mcp list --agents copilot` reports it + enabled; Copilot's session events show a successful call to the server's tool, loaded from + the user config; revert removes only ug's server, so Copilot lists just the developer's. + Limits: the service needs no connection sign-in, so the sign-in path is not covered here. + """ + session = live_session + # Ordinary pre-existing user settings, not manufactured gateway state. + user_config = session.home / ".copilot/mcp-config.json" + user_config.parent.mkdir(parents=True) + user_config.write_text(json.dumps({"mcpServers": {USER_SERVER: USER_ENTRY}}), encoding="utf-8") + user_settings = session.home / ".copilot/settings.json" + user_settings.write_text(json.dumps({"disabledMcpServers": [USER_SERVER]}), encoding="utf-8") + + session.run( + "configure", + "--agents", + "copilot", + "--workspace", + workspace, + "--skip-validate", + "--skip-upgrade", + "--disable-databricks-ai-tools", + ) + session.run( + "mcp", "add", "--agents", "copilot", "--workspace", workspace, "--names", MCP_SERVICE + ) + [server] = [ + entry["name"] + for entry in session.workspace_state()["mcp_servers"] + if MCP_SERVICE in str(entry.get("url")) + ] + + servers = _copilot_mcp_servers(session) + assert servers[server]["source"] == "user", servers + assert servers[server]["enabled"] is True, servers + assert servers[USER_SERVER]["enabled"] is False, servers + listing = session.run("mcp", "list", "--agents", "copilot").stdout + rows = [line for line in listing.splitlines() if server in line] + assert rows and all("enabled" in row for row in rows), listing + + session.record("copilot-model.json", {"model": CLAUDE_TEST_MODEL}) + session.run( + "copilot", + "--model", + CLAUDE_TEST_MODEL, + "-p", + f"Call the `{server}` web_search tool once to search for 'Databricks Unity Catalog'. " + "Then reply with the single word DONE.", + *COPILOT_HEADLESS_FLAGS, + timeout=240, + ) + calls = [call for call in copilot_mcp_tool_calls(session) if call["server"] == server] + assert calls, copilot_mcp_tool_calls(session) + assert all(call["source"] == "user" for call in calls), calls + assert any(call["succeeded"] for call in calls), calls + + with TerminalProcess(session, "ug", [str(session.binary), "revert"], "revert") as terminal: + terminal.finish() + assert set(_copilot_mcp_servers(session)) == {USER_SERVER} + assert json.loads(user_config.read_text(encoding="utf-8")) == { + "mcpServers": {USER_SERVER: USER_ENTRY} + } + settings = json.loads(user_settings.read_text(encoding="utf-8")) + assert settings["disabledMcpServers"] == [USER_SERVER], settings + assert not (session.home / ".copilot/ucode-mcp-config.json").exists() diff --git a/tests/integration/utils/agents/copilot.py b/tests/integration/utils/agents/copilot.py index 2ea73799d..4244a86df 100644 --- a/tests/integration/utils/agents/copilot.py +++ b/tests/integration/utils/agents/copilot.py @@ -2,7 +2,13 @@ from . import _evidence_id -__all__ = ["EVIDENCE_KIND", "assistant_answers", "completed_task_models", "selected_models"] +__all__ = [ + "EVIDENCE_KIND", + "assistant_answers", + "completed_task_models", + "mcp_tool_calls", + "selected_models", +] SESSION_DIRECTORY = ".copilot/session-state" EVIDENCE_KIND = "assistant message model" @@ -46,3 +52,27 @@ def selected_models(records: list[dict]) -> list[str]: elif record.get("type") == "session.model_change" and data.get("newModel"): refs.append(data["newModel"]) return refs + + +def mcp_tool_calls(records: list[dict]) -> list[dict]: + """Each MCP tool call: its server, the config source Copilot loaded it from, and whether + the call completed successfully.""" + succeeded = { + (record.get("data") or {}).get("toolCallId") + for record in records + if record.get("type") == "tool.execution_complete" + and (record.get("data") or {}).get("success") is True + } + calls = [] + for record in records: + data = record.get("data") or {} + if record.get("type") == "tool.execution_start" and data.get("mcpServerName"): + calls.append( + { + "server": data["mcpServerName"], + "tool": data.get("mcpToolName"), + "source": data.get("mcpConfigSource"), + "succeeded": data.get("toolCallId") in succeeded, + } + ) + return calls diff --git a/tests/integration/utils/evidence.py b/tests/integration/utils/evidence.py index 0a1798b8e..9fb855939 100644 --- a/tests/integration/utils/evidence.py +++ b/tests/integration/utils/evidence.py @@ -156,6 +156,15 @@ def copilot_selected_models(session) -> list[str]: ] +def copilot_mcp_tool_calls(session) -> list[dict]: + """Every MCP tool call recorded by Copilot sessions under this home.""" + return [ + call + for records in agent_sessions(session, "copilot").values() + for call in copilot.mcp_tool_calls(records) + ] + + def assert_no_session_registry_left(session) -> None: """`ug copilot` removes its per-session registry, which holds the proxy secret, on exit.""" leftovers = sorted(session.home.glob(".copilot/ug-providers-*")) diff --git a/tests/test_agent_copilot.py b/tests/test_agent_copilot.py index 2bebd3a3e..82f682cd5 100644 --- a/tests/test_agent_copilot.py +++ b/tests/test_agent_copilot.py @@ -830,9 +830,7 @@ def test_writes_mcp_server_without_clobbering_existing_config(self, tmp_path, mo monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path) config_file = tmp_path / "mcp-config.json" - backup_file = tmp_path / "copilot-mcp-backup.json" monkeypatch.setattr(cp_mod, "COPILOT_MCP_CONFIG_PATH", config_file) - monkeypatch.setattr(cp_mod, "COPILOT_MCP_BACKUP_PATH", backup_file) config_file.write_text( json.dumps( @@ -863,9 +861,7 @@ def test_reports_replaced_mcp_server(self, tmp_path, monkeypatch): monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path) config_file = tmp_path / "mcp-config.json" - backup_file = tmp_path / "copilot-mcp-backup.json" monkeypatch.setattr(cp_mod, "COPILOT_MCP_CONFIG_PATH", config_file) - monkeypatch.setattr(cp_mod, "COPILOT_MCP_BACKUP_PATH", backup_file) config_file.write_text( json.dumps({"mcpServers": {"github": {"old": True}}}), @@ -905,6 +901,51 @@ def test_removes_mcp_server_without_clobbering_others(self, tmp_path, monkeypatc assert written["other"] is True +class TestCopilotUserMcpConfig: + ENTRY = {"type": "local", "command": "ug", "args": ["mcp-proxy"], "tools": ["*"]} + + def test_honors_copilot_home(self, tmp_path, monkeypatch): + monkeypatch.setenv("COPILOT_HOME", str(tmp_path / "copilot-home")) + + copilot.write_user_mcp_servers({"svc": self.ENTRY}, set()) + + written = json.loads((tmp_path / "copilot-home" / "mcp-config.json").read_text()) + assert written["mcpServers"] == {"svc": self.ENTRY} + + def test_refuses_to_rewrite_a_config_it_cannot_parse(self): + path = copilot.copilot_mcp_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{not json", encoding="utf-8") + + with pytest.raises(RuntimeError, match="isn't a valid JSON object"): + copilot.write_user_mcp_servers({"svc": self.ENTRY}, set()) + + assert path.read_text(encoding="utf-8") == "{not json" + + def test_moves_legacy_side_file_servers_into_the_user_config(self): + path = copilot.copilot_mcp_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"mcpServers": {"mine": {"x": 1}}, "other": True})) + copilot.LEGACY_MCP_CONFIG_PATH.write_text( + json.dumps({"mcpServers": {"svc": self.ENTRY, "gone": self.ENTRY}}) + ) + copilot.LEGACY_MCP_BACKUP_PATH.write_text("{}") + + removed = copilot.write_user_mcp_servers({}, {"gone"}) + + assert removed == {"gone"} + written = json.loads(path.read_text()) + assert written["other"] is True + assert written["mcpServers"] == {"mine": {"x": 1}, "svc": self.ENTRY} + assert not copilot.LEGACY_MCP_CONFIG_PATH.exists() + assert not copilot.LEGACY_MCP_BACKUP_PATH.exists() + + def test_migration_is_a_no_op_without_a_legacy_side_file(self): + copilot.migrate_legacy_mcp_config() + + assert not copilot.copilot_mcp_config_path().exists() + + def isolate_copilot_config_paths(monkeypatch, tmp_path): import ucode.config_io as config_io import ucode.state as state_mod @@ -916,7 +957,6 @@ def isolate_copilot_config_paths(monkeypatch, tmp_path): monkeypatch.setattr(state_mod, "STATE_PATH", app_dir / "state.json") monkeypatch.setattr(copilot, "COPILOT_ENV_PATH", env_path) monkeypatch.setattr(copilot, "COPILOT_BACKUP_PATH", app_dir / "copilot-ucode-env.backup") - monkeypatch.setattr(copilot, "COPILOT_MCP_CONFIG_PATH", tmp_path / "missing-mcp.json") monkeypatch.setattr(copilot, "COPILOT_CONFIG_DIR", env_path.parent) return env_path @@ -1139,7 +1179,14 @@ def test_explicit_model_is_rewritten_to_the_registry_ref(self, tmp_path, monkeyp options=copilot.LaunchOptions(launch_model=SONNET), ) - assert seen["argv"] == ["copilot", "-p", "hi", "--model", f"dbx-responses/{GPT}"] + assert seen["argv"] == [ + "copilot", + "--allow-all-mcp-server-instructions", + "-p", + "hi", + "--model", + f"dbx-responses/{GPT}", + ] assert seen["env"]["COPILOT_MODEL"] == f"dbx-responses/{GPT}" def test_starts_on_the_requested_launch_model(self, tmp_path, monkeypatch): @@ -1320,24 +1367,20 @@ def test_has_prompt_flag(self): cmd = copilot.validate_cmd("copilot") assert "--prompt" in cmd - def test_adds_ucode_mcp_config_when_present(self, tmp_path, monkeypatch): - mcp_path = tmp_path / "ucode-mcp-config.json" - mcp_path.write_text("{}", encoding="utf-8") - monkeypatch.setattr(copilot, "COPILOT_MCP_CONFIG_PATH", mcp_path) - + def test_lets_mcp_server_instructions_reach_the_model(self): cmd = copilot.validate_cmd("copilot") - assert cmd[:3] == ["copilot", "--additional-mcp-config", f"@{mcp_path}"] + assert cmd[:2] == ["copilot", "--allow-all-mcp-server-instructions"] + assert "--additional-mcp-config" not in cmd class TestWriteUserMcpServers: def test_batched_add_remove_preserves_other_keys(self, tmp_path, monkeypatch): - path = tmp_path / "ucode-mcp-config.json" + path = tmp_path / "mcp-config.json" path.write_text( json.dumps({"other": 1, "mcpServers": {"mine": {"type": "local"}, "gone": {}}}) ) monkeypatch.setattr(copilot, "COPILOT_MCP_CONFIG_PATH", path) - monkeypatch.setattr(copilot, "COPILOT_MCP_BACKUP_PATH", tmp_path / "backup.json") copilot.write_user_mcp_servers( {"svc": copilot.build_mcp_server_entry(["ug", "mcp-proxy", "u"])}, {"gone"} diff --git a/tests/test_integration_contract.py b/tests/test_integration_contract.py index cd3c79e39..f4492b895 100644 --- a/tests/test_integration_contract.py +++ b/tests/test_integration_contract.py @@ -473,6 +473,7 @@ def test_cuj_discovery_fails_for_a_file_without_a_check_name(tmp_path): "antigravity", "managed", "managed-opencode", + "managed-copilot", "dedicated-cuj-plan", "dedicated-cuj", "cujs", diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 7791286a6..d94eb5ca5 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -3217,22 +3217,14 @@ def test_reports_schemaless_connection(self, capsys): class TestRevertMcpConfigs: - def test_removes_cli_registered_servers_and_restores_copilot_config(self, monkeypatch): + def test_removes_cli_registered_servers_from_every_client(self, monkeypatch): removed: list[tuple[str, str]] = [] - restored: list[tuple[object, object, bool]] = [] monkeypatch.setattr( mcp, "remove_client_mcp_server", lambda client, name: removed.append((client, name)) or ["user"], ) - monkeypatch.setattr( - mcp, - "restore_file", - lambda config_path, backup_path, managed: ( - restored.append((config_path, backup_path, managed)) or True - ), - ) result = mcp.revert_mcp_configs( { @@ -3252,9 +3244,6 @@ def test_removes_cli_registered_servers_and_restores_copilot_config(self, monkey ("opencode", "github-mcp"), ("copilot", "github-mcp"), ] - assert restored == [ - (mcp.copilot.COPILOT_MCP_CONFIG_PATH, mcp.copilot.COPILOT_MCP_BACKUP_PATH, True) - ] assert result == { "claude": True, "codex": True, @@ -3263,6 +3252,23 @@ def test_removes_cli_registered_servers_and_restores_copilot_config(self, monkey "copilot": True, } + def test_removes_copilot_servers_from_its_user_config_and_the_legacy_side_file(self): + user_config = mcp.copilot.copilot_mcp_config_path() + user_config.parent.mkdir(parents=True, exist_ok=True) + user_config.write_text( + json.dumps({"mcpServers": {"github-mcp": {"type": "local"}, "mine": {"x": 1}}}) + ) + legacy = mcp.copilot.LEGACY_MCP_CONFIG_PATH + legacy.write_text(json.dumps({"mcpServers": {"stale-ug": {"type": "local"}}})) + + result = mcp.revert_mcp_configs( + {"mcp_servers": [{"name": "github-mcp", "clients": ["copilot"]}]} + ) + + assert result == {"copilot": True} + assert json.loads(user_config.read_text())["mcpServers"] == {"mine": {"x": 1}} + assert not legacy.exists() + def test_removes_skills_registry_across_its_clients(self, monkeypatch): removed: list[tuple[str, str]] = [] monkeypatch.setattr( @@ -3270,7 +3276,6 @@ def test_removes_skills_registry_across_its_clients(self, monkeypatch): "remove_client_mcp_server", lambda client, name: removed.append((client, name)) or ["user"], ) - monkeypatch.setattr(mcp, "restore_file", lambda *a, **kw: False) skills_entry = mcp._resolve_skills_mcp_servers( WS, ["claude", "codex"], _by_client(["claude", "codex"], ["a.b"]), [] @@ -4350,6 +4355,23 @@ def test_parses_codex_enabled_disabled_table(self): "github": mcp.LIVE_ENABLED, } + def test_parses_copilot_json_listing(self): + output = json.dumps( + { + "mcpServers": { + "system-ai-github": {"type": "local", "source": "user", "enabled": True}, + "paused": {"type": "local", "source": "user", "enabled": False}, + } + } + ) + assert mcp.parse_mcp_list_output("copilot", output) == { + "system-ai-github": mcp.LIVE_ENABLED, + "paused": mcp.LIVE_DISABLED, + } + + def test_unreadable_copilot_listing_returns_no_servers(self): + assert mcp.parse_mcp_list_output("copilot", "error: something broke") == {} + def test_empty_listing_returns_no_servers(self): assert mcp.parse_mcp_list_output("gemini", "No MCP servers configured.") == {} @@ -4393,14 +4415,6 @@ def test_cursor_unapproved_server_is_not_reported_missing(self): output = "Loading MCPs…\nug-audit: not loaded (needs approval)\n" assert mcp.parse_mcp_list_output("cursor", output) == {"ug-audit": mcp.LIVE_NEEDS_APPROVAL} - def test_copilot_additional_config_is_reported_as_enabled(self, tmp_path, monkeypatch): - # Bare `copilot mcp list` does not include servers from --additional-mcp-config. - config = tmp_path / "ucode-mcp-config.json" - config.write_text('{"mcpServers":{"ug-audit":{"type":"local"}}}') - monkeypatch.setattr(mcp.copilot, "COPILOT_MCP_CONFIG_PATH", config) - monkeypatch.setattr(mcp, "_run_mcp_list", lambda client: "No MCP servers configured.") - assert mcp.query_live_mcp_status("copilot") == {"ug-audit": mcp.LIVE_ENABLED} - def test_ansi_progress_escapes_are_stripped_before_parsing(self, monkeypatch): # cursor-agent redraws progress with ANSI escapes that must not leak into server names. class _Result: @@ -4672,6 +4686,15 @@ def test_config_only_status_note_does_not_claim_live_probe(self, monkeypatch, ca assert "config-only status is from config, not live health" in out assert "Codex reports enabled/disabled" not in out + def test_caveat_names_codex_and_copilot_when_both_reported(self, monkeypatch, capsys): + self._patch( + monkeypatch, installed=("claude", "codex", "copilot"), live={"codex": {}, "copilot": {}} + ) + assert mcp.list_mcp_command() == 0 + assert "Codex and GitHub Copilot CLI report enabled/disabled" in _unwrap( + capsys.readouterr().out + ) + def test_codex_caveat_omitted_when_scoped_out(self, monkeypatch, capsys): # Scoping Codex out drops the Codex clause from the note. self._patch(monkeypatch, live={"claude": {}, "codex": {}}) From 0bd37c4a16128328204943b9fffef164f96a4f3a Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Mon, 26 Oct 2026 14:12:38 +0000 Subject: [PATCH 02/18] copilot: edit commented MCP configs through the copilot CLI; keep the side file until the write lands - Copilot accepts JSON with comments in mcp-config.json. When ug can't parse the file, it now applies just the changed entries with `copilot mcp remove` / `copilot mcp add`, as it does for Claude Code and Codex, instead of refusing. A file the CLI can't read either is still left alone. - The legacy side file is deleted only after the user config is written, so a failed write no longer loses ug's servers. An empty side file is still removed. - `ug mcp list` reads only the leading JSON object of `copilot mcp list --json`, so stderr after it no longer hides every server. - The managed MCP journey looks up `system-ai-github` by name. Co-authored-by: Isaac --- src/ucode/agents/copilot.py | 88 +++++++++++++------ src/ucode/config_io.py | 2 +- src/ucode/mcp.py | 12 ++- .../test_ug_configure_managed_mcp.py | 5 +- tests/test_agent_copilot.py | 67 +++++++++++++- tests/test_mcp.py | 4 + 6 files changed, 142 insertions(+), 36 deletions(-) diff --git a/src/ucode/agents/copilot.py b/src/ucode/agents/copilot.py index 34f8c6139..c9f85e7f9 100644 --- a/src/ucode/agents/copilot.py +++ b/src/ucode/agents/copilot.py @@ -29,6 +29,7 @@ import os import re import secrets +import subprocess import threading import time from collections.abc import Callable, Iterable, Iterator @@ -803,30 +804,25 @@ def build_mcp_server_entry(argv: list[str]) -> dict: def copilot_mcp_config_path() -> Path: - """The user config Copilot reads ``mcpServers`` from (and ``copilot mcp add`` writes): - ``$COPILOT_HOME/mcp-config.json`` when that env var is set, else ``~/.copilot/mcp-config.json``.""" + """The user config Copilot reads ``mcpServers`` from (and ``copilot mcp add`` writes).""" copilot_home = os.environ.get("COPILOT_HOME") return Path(copilot_home) / "mcp-config.json" if copilot_home else COPILOT_MCP_CONFIG_PATH -def _read_mcp_config_for_rewrite(path: Path) -> dict: - """``path`` parsed for a rewrite (``{}`` when absent). Raises rather than return ``{}`` for a - file that isn't a JSON object, since rewriting it would drop the developer's own servers.""" +def _read_mcp_config_for_rewrite(path: Path) -> dict | None: + """``path`` parsed for a rewrite: ``{}`` when absent, ``None`` when it isn't a JSON object, so + a caller never overwrites it. Copilot also accepts JSON with comments, which lands here.""" if not path.exists(): return {} try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError): - data = None - if not isinstance(data, dict): - raise RuntimeError( - f"Can't update {path}: it isn't a valid JSON object. Fix it, then retry." - ) - return data + return None + return data if isinstance(data, dict) else None def remove_legacy_mcp_config() -> bool: - """Delete the legacy side file and its backup (on revert: everything in them is ug's).""" + """Delete the legacy side file and its backup (everything in them is ug's).""" existed = LEGACY_MCP_CONFIG_PATH.exists() or LEGACY_MCP_BACKUP_PATH.exists() if is_dry_run(): return existed @@ -835,32 +831,74 @@ def remove_legacy_mcp_config() -> bool: return existed -def _take_legacy_mcp_servers() -> dict[str, dict]: - """The servers in the legacy side file, deleting it and its backup. Every entry there was - written by ug, so it moves into the user config as-is.""" +def _read_legacy_mcp_servers() -> dict[str, dict]: + """The servers in the legacy side file. Every entry there was written by ug, so it moves into + the user config as-is.""" servers = read_json_safe(LEGACY_MCP_CONFIG_PATH).get("mcpServers") - remove_legacy_mcp_config() if not isinstance(servers, dict): return {} return {name: entry for name, entry in servers.items() if isinstance(entry, dict)} +def _remove_mcp_server_via_cli(name: str) -> bool: + try: + result = subprocess_cross_os.run( + [SPEC["binary"], "mcp", "remove", name], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError(f"Timed out removing MCP server '{name}' via copilot CLI.") from exc + if "not found" in f"{result.stderr or ''}\n{result.stdout or ''}".lower(): + return False + if result.returncode != 0: + raise RuntimeError(f"Failed to remove MCP server '{name}' via copilot CLI.") + return True + + +def _add_mcp_server_via_cli(name: str, entry: dict) -> None: + # The CLI refuses an existing name, so a replace removes it first. + _remove_mcp_server_via_cli(name) + argv = [entry["command"], *entry.get("args", [])] + try: + subprocess_cross_os.run( + [SPEC["binary"], "mcp", "add", name, "--", *argv], + check=True, + capture_output=True, + text=True, + timeout=30, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + raise RuntimeError(f"Failed to add MCP server '{name}' via copilot CLI.") from exc + + def _apply_mcp_diff(add: dict[str, dict], remove: set[str]) -> set[str]: path = copilot_mcp_config_path() config = _read_mcp_config_for_rewrite(path) + legacy = _read_legacy_mcp_servers() + upserts = {name: entry for name, entry in legacy.items() if name not in remove} | add + if config is None: + # Defer to the copilot CLI, which reads the file the way Copilot does, for exactly the + # changed entries. + removed = {name for name in remove if _remove_mcp_server_via_cli(name) or name in legacy} + for name, entry in upserts.items(): + _add_mcp_server_via_cli(name, entry) + remove_legacy_mcp_config() + return removed + servers = config.get("mcpServers") if not isinstance(servers, dict): servers = {} - legacy = _take_legacy_mcp_servers() removed = {name for name in remove if name in servers or name in legacy} - if not add and not removed and not legacy: - return removed - servers.update({name: entry for name, entry in legacy.items() if name not in remove}) - for name in remove: - servers.pop(name, None) - servers.update(add) - config["mcpServers"] = servers - write_json_file(path, config) + if upserts or removed: + for name in remove: + servers.pop(name, None) + servers.update(upserts) + config["mcpServers"] = servers + write_json_file(path, config) + remove_legacy_mcp_config() return removed diff --git a/src/ucode/config_io.py b/src/ucode/config_io.py index c4ca3578c..85ce794ac 100644 --- a/src/ucode/config_io.py +++ b/src/ucode/config_io.py @@ -185,7 +185,7 @@ def apply_json_mcp_diff( ) -> set[str]: """Apply an ``add``/``remove`` MCP-server diff to the ``key`` map in a JSON config, in one read-modify-write. Shared by the agents whose MCP servers live in a JSON object keyed by name - (copilot/cursor/gemini/opencode); the developer's own entries and every other key are kept. + (cursor/gemini/kilo/opencode); the developer's own entries and every other key are kept. ``backup_path`` snapshots the file first when the agent keeps a revert backup. Returns the subset of ``remove`` names that were actually present (so callers can report only real removals).""" if backup_path is not None: diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index 596a97c7c..4c070dada 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -2597,12 +2597,16 @@ def _parse_tree_mcp_list(output: str) -> dict[str, str]: def _parse_copilot_mcp_list(output: str) -> dict[str, str]: """Parse `copilot mcp list --json` into ``{server_name: state}``. - Like Codex, Copilot reports config state (``enabled``), not a health probe. Output that isn't - the expected JSON object contributes nothing.""" + Like Codex, Copilot reports config state (``enabled``), not a health probe. Only the leading + JSON object is read, since stderr follows it; output without one contributes nothing.""" + start = output.find("{") + if start < 0: + return {} try: - servers = json.loads(output[output.find("{") :]).get("mcpServers") - except (json.JSONDecodeError, AttributeError): + data, _ = json.JSONDecoder().raw_decode(output, start) + except json.JSONDecodeError: return {} + servers = data.get("mcpServers") if isinstance(data, dict) else None if not isinstance(servers, dict): return {} return { diff --git a/tests/integration/test_ug_configure_managed_mcp.py b/tests/integration/test_ug_configure_managed_mcp.py index 2c94f5019..072f32586 100644 --- a/tests/integration/test_ug_configure_managed_mcp.py +++ b/tests/integration/test_ug_configure_managed_mcp.py @@ -133,9 +133,8 @@ def test_managed_fixture_copilot_mcp_registered_in_user_config(live_session, wor listing = session.run("mcp", "list", "--json", binary=session.which("copilot"), timeout=60) servers = json.loads(listing.stdout)["mcpServers"] - entries = {name: entry for name, entry in servers.items() if "github" in name.lower()} - assert entries, servers - entry = next(iter(entries.values())) + assert "system-ai-github" in servers, servers + entry = servers["system-ai-github"] assert entry["source"] == "user", entry assert entry["enabled"] is True, entry assert entry["command"].endswith("ug"), entry diff --git a/tests/test_agent_copilot.py b/tests/test_agent_copilot.py index 82f682cd5..3f102ddcd 100644 --- a/tests/test_agent_copilot.py +++ b/tests/test_agent_copilot.py @@ -5,6 +5,7 @@ import contextlib import json import os +import subprocess from pathlib import Path import httpx @@ -912,15 +913,75 @@ def test_honors_copilot_home(self, tmp_path, monkeypatch): written = json.loads((tmp_path / "copilot-home" / "mcp-config.json").read_text()) assert written["mcpServers"] == {"svc": self.ENTRY} - def test_refuses_to_rewrite_a_config_it_cannot_parse(self): + @staticmethod + def _stub_cli(monkeypatch, *, fail_add=False): + calls: list[list[str]] = [] + + def run(argv, **kwargs): + calls.append(argv) + if argv[2] == "add" and fail_add: + raise subprocess.CalledProcessError(1, argv) + stderr = f'Server "{argv[3]}" not found.' if argv[2] == "remove" else "" + return subprocess.CompletedProcess(argv, 1 if stderr else 0, "", stderr) + + monkeypatch.setattr(copilot.subprocess_cross_os, "run", run) + return calls + + def test_defers_to_the_copilot_cli_for_a_config_it_cannot_parse(self, monkeypatch): + calls = self._stub_cli(monkeypatch) + path = copilot.copilot_mcp_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + jsonc = '{\n // mine\n "mcpServers": {"mine": {"x": 1},},\n}\n' + path.write_text(jsonc, encoding="utf-8") + copilot.LEGACY_MCP_CONFIG_PATH.write_text(json.dumps({"mcpServers": {"old": self.ENTRY}})) + + removed = copilot.write_user_mcp_servers({"svc": self.ENTRY}, {"gone"}) + + assert removed == set() + assert calls == [ + ["copilot", "mcp", "remove", "gone"], + ["copilot", "mcp", "remove", "old"], + ["copilot", "mcp", "add", "old", "--", "ug", "mcp-proxy"], + ["copilot", "mcp", "remove", "svc"], + ["copilot", "mcp", "add", "svc", "--", "ug", "mcp-proxy"], + ] + assert path.read_text(encoding="utf-8") == jsonc + assert not copilot.LEGACY_MCP_CONFIG_PATH.exists() + + def test_failed_cli_add_keeps_the_legacy_side_file(self, monkeypatch): + self._stub_cli(monkeypatch, fail_add=True) path = copilot.copilot_mcp_config_path() path.parent.mkdir(parents=True, exist_ok=True) path.write_text("{not json", encoding="utf-8") + copilot.LEGACY_MCP_CONFIG_PATH.write_text(json.dumps({"mcpServers": {"old": self.ENTRY}})) - with pytest.raises(RuntimeError, match="isn't a valid JSON object"): - copilot.write_user_mcp_servers({"svc": self.ENTRY}, set()) + with pytest.raises(RuntimeError, match="Failed to add MCP server 'old'"): + copilot.migrate_legacy_mcp_config() assert path.read_text(encoding="utf-8") == "{not json" + assert copilot.LEGACY_MCP_CONFIG_PATH.exists() + + def test_failed_write_keeps_the_legacy_side_file(self, monkeypatch): + def fail(path, payload): + raise RuntimeError(f"Failed to write config file: {path}") + + monkeypatch.setattr(copilot, "write_json_file", fail) + copilot.LEGACY_MCP_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + copilot.LEGACY_MCP_CONFIG_PATH.write_text(json.dumps({"mcpServers": {"old": self.ENTRY}})) + + with pytest.raises(RuntimeError, match="Failed to write config file"): + copilot.migrate_legacy_mcp_config() + + assert copilot.LEGACY_MCP_CONFIG_PATH.exists() + + def test_empty_legacy_side_file_is_removed_without_a_write(self): + copilot.LEGACY_MCP_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + copilot.LEGACY_MCP_CONFIG_PATH.write_text("{}") + + copilot.migrate_legacy_mcp_config() + + assert not copilot.LEGACY_MCP_CONFIG_PATH.exists() + assert not copilot.copilot_mcp_config_path().exists() def test_moves_legacy_side_file_servers_into_the_user_config(self): path = copilot.copilot_mcp_config_path() diff --git a/tests/test_mcp.py b/tests/test_mcp.py index d94eb5ca5..40bfecf62 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -4369,6 +4369,10 @@ def test_parses_copilot_json_listing(self): "paused": mcp.LIVE_DISABLED, } + def test_copilot_listing_followed_by_stderr_still_parses(self): + output = json.dumps({"mcpServers": {"svc": {"enabled": True}}}) + "\nwarning: slow start\n" + assert mcp.parse_mcp_list_output("copilot", output) == {"svc": mcp.LIVE_ENABLED} + def test_unreadable_copilot_listing_returns_no_servers(self): assert mcp.parse_mcp_list_output("copilot", "error: something broke") == {} From 309755b85564b4718c3ea7e989942f1f4048313e Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Mon, 26 Oct 2026 14:35:56 +0000 Subject: [PATCH 03/18] mcp: keep reverting the other agents when one agent's MCP removal fails - `ug revert` now warns and moves on when removing a server from one agent fails (for example a Copilot config neither ug nor the copilot CLI can read), instead of stopping before the other agents and the rest of the revert. - Deleting Copilot's legacy side file raises a RuntimeError rather than a raw OSError, so `ug copilot` warns and still launches when it can't. Co-authored-by: Isaac --- src/ucode/agents/copilot.py | 7 +++++-- src/ucode/mcp.py | 15 ++++++++++++--- tests/test_agent_copilot.py | 12 ++++++++++++ tests/test_mcp.py | 21 +++++++++++++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/ucode/agents/copilot.py b/src/ucode/agents/copilot.py index c9f85e7f9..be2e57353 100644 --- a/src/ucode/agents/copilot.py +++ b/src/ucode/agents/copilot.py @@ -826,8 +826,11 @@ def remove_legacy_mcp_config() -> bool: existed = LEGACY_MCP_CONFIG_PATH.exists() or LEGACY_MCP_BACKUP_PATH.exists() if is_dry_run(): return existed - LEGACY_MCP_CONFIG_PATH.unlink(missing_ok=True) - LEGACY_MCP_BACKUP_PATH.unlink(missing_ok=True) + for path in (LEGACY_MCP_CONFIG_PATH, LEGACY_MCP_BACKUP_PATH): + try: + path.unlink(missing_ok=True) + except OSError as exc: + raise RuntimeError(f"Failed to remove {path}") from exc return existed diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index 4c070dada..28f415485 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -498,8 +498,12 @@ def revert_mcp_configs(state: dict) -> dict[str, bool]: all_servers = list(state.get("mcp_servers") or []) + list( state.get("managed_mcp_servers") or [] ) - if copilot.remove_legacy_mcp_config(): - results["copilot"] = True + # One agent's unreadable config must not stop the revert of the others. + try: + if copilot.remove_legacy_mcp_config(): + results["copilot"] = True + except RuntimeError as exc: + print_warning(f"Could not remove ug's old Copilot MCP file: {exc}") for server in all_servers: name = server.get("name") if not isinstance(name, str) or not name: @@ -507,7 +511,12 @@ def revert_mcp_configs(state: dict) -> dict[str, bool]: for client in server.get("clients") or []: if client not in MCP_CLIENTS: continue - removed_scopes = remove_client_mcp_server(client, name) + try: + removed_scopes = remove_client_mcp_server(client, name) + except RuntimeError as exc: + display = MCP_CLIENTS[client]["display"] + print_warning(f"Could not remove MCP server `{name}` from {display}: {exc}") + continue results[client] = bool(removed_scopes) or results.get(client, False) return results diff --git a/tests/test_agent_copilot.py b/tests/test_agent_copilot.py index 3f102ddcd..f416b1ad2 100644 --- a/tests/test_agent_copilot.py +++ b/tests/test_agent_copilot.py @@ -974,6 +974,18 @@ def fail(path, payload): assert copilot.LEGACY_MCP_CONFIG_PATH.exists() + def test_a_legacy_side_file_that_cannot_be_deleted_raises_a_runtime_error(self, monkeypatch): + copilot.LEGACY_MCP_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + copilot.LEGACY_MCP_CONFIG_PATH.write_text("{}") + + def unlink(self, missing_ok=False): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(Path, "unlink", unlink) + + with pytest.raises(RuntimeError, match="Failed to remove"): + copilot.remove_legacy_mcp_config() + def test_empty_legacy_side_file_is_removed_without_a_write(self): copilot.LEGACY_MCP_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) copilot.LEGACY_MCP_CONFIG_PATH.write_text("{}") diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 40bfecf62..ee64e8349 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -3252,6 +3252,27 @@ def test_removes_cli_registered_servers_from_every_client(self, monkeypatch): "copilot": True, } + def test_one_agents_failed_removal_does_not_stop_the_others(self, monkeypatch, capsys): + removed: list[tuple[str, str]] = [] + + def remove(client, name): + if client == "copilot": + raise RuntimeError("Failed to remove MCP server via copilot CLI.") + removed.append((client, name)) + return ["user"] + + monkeypatch.setattr(mcp, "remove_client_mcp_server", remove) + + result = mcp.revert_mcp_configs( + {"mcp_servers": [{"name": "svc", "clients": ["copilot", "claude", "codex"]}]} + ) + + assert removed == [("claude", "svc"), ("codex", "svc")] + assert result == {"claude": True, "codex": True} + assert "Could not remove MCP server `svc` from GitHub Copilot CLI" in _unwrap( + capsys.readouterr().out + ) + def test_removes_copilot_servers_from_its_user_config_and_the_legacy_side_file(self): user_config = mcp.copilot.copilot_mcp_config_path() user_config.parent.mkdir(parents=True, exist_ok=True) From 9d3501ab5013d0ac3d073fa4f42b059e656c90c6 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Fri, 30 Oct 2026 12:18:05 +0000 Subject: [PATCH 04/18] tests: move the managed Copilot lane to the managed-config follow-up The gateway does not accept CODING_AGENT_COPILOT managed configs, so the lane only exercised a config the stub injects. The personal journey test_ug_copilot_mcp.py covers the user-config writer end to end. Co-authored-by: Isaac --- .github/workflows/integration.yml | 57 ------------------- tests/AGENTS.md | 8 +-- tests/README.md | 1 - .../fixtures/managed_config/copilot_mcp.json | 24 -------- .../copilot_skills_by_name.json | 24 -------- tests/integration/README.md | 17 ++---- .../test_ug_configure_managed_mcp.py | 27 +-------- .../test_ug_configure_managed_skills.py | 27 +-------- tests/test_integration_contract.py | 1 - 9 files changed, 13 insertions(+), 173 deletions(-) delete mode 100644 tests/fixtures/managed_config/copilot_mcp.json delete mode 100644 tests/fixtures/managed_config/copilot_skills_by_name.json diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index d51aaca4d..519c3be28 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -486,63 +486,6 @@ jobs: ${{ runner.temp }}/ug-integration/artifacts/ ${{ runner.temp }}/ug-integration/wheels/ - # Non-blocking like the Copilot lane: continue-on-error and absent from the cujs `needs`. - # Runs the Copilot managed_fixture cases on the managed e2e workspace with injected configs that - # enable Copilot (a service principal authenticates it, like the `managed` lane). - managed-copilot: - name: Managed config · Copilot (non-blocking) - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - continue-on-error: true - runs-on: ubuntu-22.04 - timeout-minutes: 30 - env: - UCODE_TEST_WORKSPACE: ${{ secrets.E2E_ADMIN_WORKSPACE }} - DATABRICKS_CLIENT_ID: ${{ secrets.E2E_ADMIN_SP_CLIENT_ID }} - DATABRICKS_CLIENT_SECRET: ${{ secrets.E2E_ADMIN_SP_CLIENT_SECRET }} - DEPENDENCY: ${{ inputs.dependency }} - TEST_MARKER: managed_fixture and copilot - ARTIFACT_NAME: integration-managed-copilot - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ inputs.ref || github.sha }} - persist-credentials: false - fetch-depth: 0 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 22.19.0 - - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - version: 0.9.8 - enable-cache: ${{ inputs.ref == '' }} - - uses: databricks/setup-cli@bdb89f81c11a5bd647fd55b585b7c396ec68a25a # v1.0.0 - with: - version: 1.17.0 - - name: Run the Copilot managed cases against the managed workspace - shell: bash - run: | - args=(--copilot-version "$COPILOT_VERSION") - if [[ -n "$DEPENDENCY" ]]; then - args+=(--dependency "$DEPENDENCY") - fi - uv run --no-project --python 3.12 python scripts/run_integration.py \ - --python 3.12 --ug-version "$UG_VERSION" --entry-point "$ENTRY_POINT" \ - --default-index "$PACKAGE_INDEX" --output "$RUNNER_TEMP/ug-integration" \ - "${args[@]}" -- -m "$TEST_MARKER" - - name: Upload managed Copilot evidence - if: ${{ !cancelled() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: ${{ env.ARTIFACT_NAME }} - include-hidden-files: true - path: | - ${{ runner.temp }}/ug-integration/*.json - ${{ runner.temp }}/ug-integration/*.txt - ${{ runner.temp }}/ug-integration/*.xml - ${{ runner.temp }}/ug-integration/*.log - ${{ runner.temp }}/ug-integration/artifacts/ - ${{ runner.temp }}/ug-integration/wheels/ - # managed and dedicated-cuj stay Linux-only: ug has no Windows managed-settings # writer, and the CUJ suite drives agents through a POSIX PTY. managed: diff --git a/tests/AGENTS.md b/tests/AGENTS.md index e2fbe07da..4c16701f5 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -93,10 +93,10 @@ exercise. and run documentation at the suite root. - Current scope is basic Claude/Codex configuration, routing, script usage, command forwarding, and configure/revert CUJs, plus Copilot CLI headless - model tasks, one TUI model-switch journey, and Copilot MCP/skills journeys matching the - Claude/Codex ones (non-blocking lanes), plus Antigravity CLI configure, TUI, headless, - External Models, MCP-tool, skill-directory, and revert journeys (blocking Linux lane). - Do not add other MCP/skills functionality, + model tasks, one TUI model-switch journey, and one MCP add, tool-call and revert journey + (non-blocking lane), plus Antigravity CLI configure, TUI, headless, External Models, + MCP-tool, skill-directory, and revert journeys (blocking Linux lane). Do not add other + MCP/skills functionality, tracing, or the broad configure-option matrix without a new scope request. Existing `mcp --help` checks cover dispatch only. diff --git a/tests/README.md b/tests/README.md index 747bb73de..4109bf642 100644 --- a/tests/README.md +++ b/tests/README.md @@ -753,7 +753,6 @@ integration utilities; only CUJ-specific evidence correlation stays in a test fi | `test_ug_copilot_refreshes_a_revoked_token_mid_session`, `test_ug_copilot_reports_expired_sign_in` | Sign in through `DATABRICKS_BEARER_COMMAND` minting short-lived PATs; mid-task the agent revokes them (or also signs out) | Revoked: a token is minted after the revocation and the task completes without restarting Copilot. Signed out: output shows "Databricks sign-in expired" with `databricks auth login`; the failure is in `~/.ucode/logs/copilot-proxy.log`, not the terminal (non-blocking CI lane) | | `test_ug_configure_copilot_repeat_and_revert` | Configure Copilot twice over a user-owned `~/.copilot/mcp-config.json`, run a GPT task, revert twice | Task completes; no file holds the bearer; revert removes `~/.copilot/ucode.env`, leaves the user file byte-identical and reports Not Configured. (non-blocking CI lane) | | `test_ug_mcp_add_copilot_registers_server_in_copilot_config` | Over a developer-owned, disabled server in `~/.copilot/mcp-config.json`, configure Copilot, `ug mcp add --agents copilot --names system.ai.web_search`, run a headless task that calls the tool, then revert | `copilot mcp list --json` lists ug's server from the user config, enabled, next to the developer's still-disabled one; `ug mcp list --agents copilot` reports it enabled; Copilot's session events record a successful call loaded from the user config; revert leaves only the developer's server and no legacy `ucode-mcp-config.json` (non-blocking CI lane) | -| `test_managed_fixture_copilot_mcp_registered_in_user_config`, `test_managed_fixture_copilot_skills_lists_downloaded_skill` | Non-interactive configure with an injected config enabling Copilot with a managed MCP server or a managed skill | `copilot mcp list --json` lists the managed server from the user config as the ug mcp-proxy command; `copilot skill list --json` lists the downloaded skill as an enabled personal skill (non-blocking managed Copilot lane) | | `test_ug_copilot_upgrade_scrubs_token_saved_by_older_ug` | Configure Copilot with ug v0.2.0 (pinned commit; saves the bearer in `~/.copilot/ucode.env`), then configure with this ug, run a GPT task, revert | v0.2.0 left the bearer; after this ug's configure no file holds it; task completes; revert leaves no token and no `ucode.env` (non-blocking CI lane) | | `test_ug_opencode_headless_prompt_argument` | Run OpenCode from a script (`run --format json --auto`) with an argument prompt | Completed Read tool call; final text answer contains the file value; exit zero (non-blocking CI lane) | | `test_ug_agents_self_managed_opencode_journey` | Under the injected `managed_workspace_default` config (enables Claude/Codex, not OpenCode): bare configure, `ug agents list`, refused OpenCode launch, `ug agents add opencode`, real headless task, `ug agents remove opencode` (marker `managed_fixture and opencode`, non-blocking CI lane) | OpenCode absent from the list and launch fails with "doesn't enable OpenCode" / `ug agents add opencode`; after add it is listed self-managed and the headless Read task returns the fixture value; after remove it is hidden and refused again | diff --git a/tests/fixtures/managed_config/copilot_mcp.json b/tests/fixtures/managed_config/copilot_mcp.json deleted file mode 100644 index 8cec6a864..000000000 --- a/tests/fixtures/managed_config/copilot_mcp.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "spec_version": 1, - "default_agent": "CODING_AGENT_COPILOT", - "enabled_agents": [ - { - "agent": "CODING_AGENT_COPILOT", - "config": { - "models": { - "model_services": [ - "system.ai.claude-sonnet-4-6" - ] - }, - "default_models": { - "default_model": "system.ai.claude-sonnet-4-6" - } - } - } - ], - "mcp_servers": { - "names": [ - "system.ai.github" - ] - } -} diff --git a/tests/fixtures/managed_config/copilot_skills_by_name.json b/tests/fixtures/managed_config/copilot_skills_by_name.json deleted file mode 100644 index 0d6c2d02b..000000000 --- a/tests/fixtures/managed_config/copilot_skills_by_name.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "spec_version": 1, - "default_agent": "CODING_AGENT_COPILOT", - "enabled_agents": [ - { - "agent": "CODING_AGENT_COPILOT", - "config": { - "models": { - "model_services": [ - "system.ai.claude-sonnet-4-6" - ] - }, - "default_models": { - "default_model": "system.ai.claude-sonnet-4-6" - } - } - } - ], - "skills": { - "names": [ - "main.default.forkable-meals" - ] - } -} diff --git a/tests/integration/README.md b/tests/integration/README.md index 273e92c7c..4aaa286cf 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -366,10 +366,7 @@ Anthropic service's model, on the OpenAI, Azure OpenAI and Microsoft Foundry ser on a named model of the Bedrock allow-all service; launching on the relayed service fails before Copilot starts. `test_ug_copilot_mcp.py` adds `system.ai.web_search` with `ug mcp add --agents copilot` over a developer's own disabled server, checks Copilot's `copilot mcp list --json` and `ug mcp list`, runs -a headless task whose tool call Copilot's session events record as successful, and reverts. The -Copilot cases in `test_ug_configure_managed_mcp.py` and `test_ug_configure_managed_skills.py` check -that a managed server and a managed skill reach Copilot (`copilot mcp list --json`, `copilot skill -list --json`). +a headless task whose tool call Copilot's session events record as successful, and reverts. Antigravity CLI has five live headless journeys in `test_ug_antigravity_headless.py` and three in `test_ug_configure_antigravity.py`. Headless runs use `agy -p --output-format stream-json`; the @@ -717,8 +714,8 @@ test_ug_claude_model_discovery.py # unmanaged scenarios 9, 11, 13 test_ug_codex_model_discovery.py # unmanaged scenarios 10, 12, 14 test_ug_configure_managed.py # stubbed static model list/catalog pointer, no agent selector, saved-config replay test_ug_configure_managed_models.py # stubbed model sources, smart-routing banner, Codex fallback metadata -test_ug_configure_managed_mcp.py # stubbed managed MCP list (Claude, Codex, Copilot) -test_ug_configure_managed_skills.py # stubbed managed skills: download, coexist, reconcile away (Claude, Codex, Copilot) +test_ug_configure_managed_mcp.py # stubbed managed MCP list +test_ug_configure_managed_skills.py # stubbed managed skills: download, coexist, reconcile away test_ug_configure_managed_lifecycle.py # none -> A -> B -> MPS -> none: reconcile, clear on MPS/no-config test_installation.py # fresh installed package utils/ # process/terminal/evidence helpers and Docker files @@ -926,9 +923,9 @@ cases instead complete real file tasks through `--workspace` and `--model-locati journeys check setup and launch with existing services, without inference. There are **88 live cases** (including 12 marked TUI journeys) and **7 installation -checks** with Claude and Codex; selecting OpenCode adds one live headless case and two `managed_fixture` cases; selecting Copilot adds eighteen live cases and two `managed_fixture` cases; selecting Antigravity adds eight live cases (one TUI). One +checks** with Claude and Codex; selecting OpenCode adds one live headless case and two `managed_fixture` cases; selecting Copilot adds eighteen live cases; selecting Antigravity adds eight live cases (one TUI). One **`workspace_switch` case** uses two real workspaces and checks skills MCP cleanup and a completed -Claude task. A further **46 `managed_fixture` cases** (twelve of them also `live`) run on the +Claude task. A further **44 `managed_fixture` cases** (twelve of them also `live`) run on the managed workspace with a checked-in JSON CodingAgentConfig from `tests/fixtures/managed_config/` injected through `UCODE_MANAGED_CONFIG_STUB`; there are no cases that read a published config. Twelve explicit configured/fresh Claude and Codex discovery and source-override journeys use the @@ -1176,9 +1173,7 @@ in `test_ug_agents_self_managed.py`), installs Claude, Codex and OpenCode and ru managed e2e workspace (`E2E_ADMIN_WORKSPACE`) with the injected `managed_workspace_default` config, which enables Claude/Codex but not OpenCode. It is likewise outside `cujs`. A non-blocking **Copilot** job (`live and copilot`, seventeen cases) runs on Linux and Windows the -same way and is likewise outside `cujs` until it is stable. A non-blocking **Managed config · Copilot** -job (`managed_fixture and copilot`, two cases) installs only Copilot and runs on the managed e2e -workspace with injected configs that enable Copilot; it is likewise outside `cujs`. +same way and is likewise outside `cujs` until it is stable. A blocking **Antigravity** job (`live and antigravity`, eight cases) runs on Linux only with the pinned `ANTIGRAVITY_BUILD`; it is part of the `cujs` gate. diff --git a/tests/integration/test_ug_configure_managed_mcp.py b/tests/integration/test_ug_configure_managed_mcp.py index 072f32586..bd158cae2 100644 --- a/tests/integration/test_ug_configure_managed_mcp.py +++ b/tests/integration/test_ug_configure_managed_mcp.py @@ -10,11 +10,9 @@ the developer's own ~/.codex/config.toml servers. A non-interactive configure has no sudo reconcile, so it falls back to the user-scope registration, which the real /mcp view still lists. Claude's `managedMcpServers` path is covered by unit tests; its integration coverage needs the managed -workspace to publish the `claude-code` OAuth client, which E2E_ADMIN_WORKSPACE does not. Copilot -has no OS-managed MCP file, so its managed servers land in its user config, ~/.copilot/mcp-config.json. +workspace to publish the `claude-code` OAuth client, which E2E_ADMIN_WORKSPACE does not. """ -import json import tomllib import pytest @@ -116,26 +114,3 @@ def test_managed_fixture_codex_http_headers_in_managed_file(live_session, worksp f"Expected {managed_header_key!r} = {managed_header_value!r}, " f"got {headers[managed_header_key]!r}" ) - - -@pytest.mark.managed_fixture -@pytest.mark.copilot -def test_managed_fixture_copilot_mcp_registered_in_user_config(live_session, workspace): - """Scenario: a non-interactive configure with a managed MCP server enabled for Copilot. - - Expected: Copilot's own `copilot mcp list --json` lists the server from its user config, - enabled, as the ug mcp-proxy stdio command for the server's mcp-services URL. - """ - session = live_session - use_managed_config_fixture(session, "copilot_mcp") - result = session.run("configure", "--workspace", workspace, "--skip-upgrade", timeout=240) - assert "Select coding agents to configure:" not in result.stdout, result.stdout - - listing = session.run("mcp", "list", "--json", binary=session.which("copilot"), timeout=60) - servers = json.loads(listing.stdout)["mcpServers"] - assert "system-ai-github" in servers, servers - entry = servers["system-ai-github"] - assert entry["source"] == "user", entry - assert entry["enabled"] is True, entry - assert entry["command"].endswith("ug"), entry - assert "mcp-services" in entry["args"][entry["args"].index("--url") + 1], entry diff --git a/tests/integration/test_ug_configure_managed_skills.py b/tests/integration/test_ug_configure_managed_skills.py index f650c1e4d..9a96eda57 100644 --- a/tests/integration/test_ug_configure_managed_skills.py +++ b/tests/integration/test_ug_configure_managed_skills.py @@ -3,14 +3,12 @@ The admin CodingAgentConfig is a checked-in JSON fixture injected via UCODE_MANAGED_CONFIG_STUB so the real /skills TUI can be driven against a managed skill; only the config INPUT is stubbed (auth, the skill download, the config writers, and the agent binary stay real). These assert what the agent presents, not the bundles on disk (that is unit tests' job). See -tests/AGENTS.md rule 4. Claude reads ~/.claude/skills and Codex and Copilot read the shared -~/.agents/skills, both of which `ug configure` writes, so a managed skill reaches each agent. The reconcile +tests/AGENTS.md rule 4. Claude reads ~/.claude/skills and Codex reads the shared ~/.agents/skills, +both of which `ug configure` writes, so a managed skill reaches either agent. The reconcile lifecycle test additionally checks the on-disk bundles, since removal-on-reconcile is a disk change that has no steady-state `/skills` signal. """ -import json - import pytest from utils.managed import use_managed_config_fixture from utils.terminal import AgentTerminal @@ -81,27 +79,6 @@ def test_managed_fixture_codex_skills_lists_downloaded_skill(live_session, works ) -@pytest.mark.managed_fixture -@pytest.mark.copilot -def test_managed_fixture_copilot_skills_lists_downloaded_skill(live_session, workspace): - """Scenario: configure Copilot under an injected config that names a managed skill, then list - Copilot's skills. - - Expected: Copilot's own `copilot skill list --json` lists the downloaded managed skill as an - enabled personal skill, read from the shared ~/.agents/skills dir `ug configure` writes. - """ - session = live_session - use_managed_config_fixture(session, "copilot_skills_by_name") - result = session.run("configure", "--workspace", workspace, "--skip-upgrade", timeout=240) - assert "Select coding agents to configure:" not in result.stdout, result.stdout - - listing = session.run("skill", "list", "--json", binary=session.which("copilot"), timeout=60) - skills = [skill for skill in json.loads(listing.stdout) if skill.get("name") == SKILL_NAME] - assert skills, listing.stdout - assert all(skill["enabled"] is True for skill in skills), skills - assert any(str(skill["source"]).startswith("personal") for skill in skills), skills - - @pytest.mark.managed_fixture @pytest.mark.claude def test_managed_skills_reconcile_lifecycle(live_session, workspace): diff --git a/tests/test_integration_contract.py b/tests/test_integration_contract.py index f4492b895..cd3c79e39 100644 --- a/tests/test_integration_contract.py +++ b/tests/test_integration_contract.py @@ -473,7 +473,6 @@ def test_cuj_discovery_fails_for_a_file_without_a_check_name(tmp_path): "antigravity", "managed", "managed-opencode", - "managed-copilot", "dedicated-cuj-plan", "dedicated-cuj", "cujs", From 784438bd73af68ce5faf3f091947fd2d6d2c5207 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Fri, 30 Oct 2026 12:42:36 +0000 Subject: [PATCH 05/18] copilot: give VS Code ug's MCP servers and skills without the Copilot CLI Copilot Chat in VS Code reads ~/.copilot/mcp-config.json, which ug now writes, but every MCP and skills command required the copilot binary, so a VS Code-only machine got neither. - A successful `ug configure --agents vscode` records the workspace as set up for VS Code, which makes the copilot MCP client eligible without the Copilot CLI. - `--agents vscode` on `ug mcp add/list/remove` and `ug skills add/remove --via mcp` names that client. Alone it never sets up the copilot agent (which would install the Copilot CLI); it asks for `ug configure --agents vscode` first. - `ug mcp list` reads Copilot's user config when there's no CLI to ask. - A config ug can't parse (comments) and no Copilot CLI now fails with a clear error instead of an uncaught FileNotFoundError, so `ug revert` reports it and carries on. Downloaded skills need nothing new: VS Code reads ~/.agents/skills. Co-authored-by: Isaac --- src/ucode/agents/copilot.py | 13 ++++++++ src/ucode/cli.py | 43 ++++++++++++++++++++++----- src/ucode/mcp.py | 37 +++++++++++++++++++---- tests/test_agent_copilot.py | 25 ++++++++++++++++ tests/test_agent_vscode.py | 19 ++++++++++++ tests/test_cli.py | 43 +++++++++++++++++++++++++++ tests/test_mcp.py | 59 +++++++++++++++++++++++++++++++++++++ 7 files changed, 227 insertions(+), 12 deletions(-) diff --git a/src/ucode/agents/copilot.py b/src/ucode/agents/copilot.py index be2e57353..689055b98 100644 --- a/src/ucode/agents/copilot.py +++ b/src/ucode/agents/copilot.py @@ -29,6 +29,7 @@ import os import re import secrets +import shutil import subprocess import threading import time @@ -821,6 +822,12 @@ def _read_mcp_config_for_rewrite(path: Path) -> dict | None: return data if isinstance(data, dict) else None +def user_mcp_server_names() -> set[str]: + """The servers in Copilot's user config, read without the Copilot CLI.""" + servers = (_read_mcp_config_for_rewrite(copilot_mcp_config_path()) or {}).get("mcpServers") + return set(servers) if isinstance(servers, dict) else set() + + def remove_legacy_mcp_config() -> bool: """Delete the legacy side file and its backup (everything in them is ug's).""" existed = LEGACY_MCP_CONFIG_PATH.exists() or LEGACY_MCP_BACKUP_PATH.exists() @@ -883,6 +890,12 @@ def _apply_mcp_diff(add: dict[str, dict], remove: set[str]) -> set[str]: legacy = _read_legacy_mcp_servers() upserts = {name: entry for name, entry in legacy.items() if name not in remove} | add if config is None: + if not shutil.which(SPEC["binary"]): + raise RuntimeError( + f"{path} isn't plain JSON (it may have comments), and ug edits such a file only " + "through the Copilot CLI. Remove the comments, then try again; VS Code's local " + "agent also skips the file while it has them." + ) # Defer to the copilot CLI, which reads the file the way Copilot does, for exactly the # changed entries. removed = {name for name in remove if _remove_mcp_server_via_cli(name) or name in legacy} diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 853a6f789..afe2be059 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -142,6 +142,7 @@ from ucode.mcp import ( CONFIG_WRITABLE_MCP_CLIENTS, MCP_CLIENTS, + VSCODE_CONFIGURED_KEY, McpServiceListingRateLimited, add_mcp_command, add_skills_command, @@ -152,6 +153,7 @@ configured_mcp_clients, configured_mcp_servers_by_name, list_mcp_command, + mcp_clients_for_agents, purge_cross_workspace_mcp_residue, reconcile_managed_mcp_servers, remove_mcp_command, @@ -1999,10 +2001,19 @@ def _configure_agents_for_mcp( workspace_entries: list[tuple[str, str | None]] | None = None, ) -> set[str]: """Select MCP clients for one workspace without changing agent model settings.""" - scope = {a if a == "cursor" else normalize_tool(a) for a in requested} + names = {a if a in ("cursor", "vscode") else normalize_tool(a) for a in requested} + scope = mcp_clients_for_agents(names) installed = available_mcp_clients() - # A named client whose user MCP config UG writes directly needs no CLI on PATH. - not_installed = sorted(scope - set(installed) - CONFIG_WRITABLE_MCP_CLIENTS.keys()) + # A named client whose user MCP config UG writes directly needs no CLI on PATH. VS Code reads + # Copilot's user config, so `vscode` alone needs a VS Code setup for the workspace rather than + # the Copilot CLI; that's checked once the workspace is selected. + vscode_only = "vscode" in names and "copilot" not in names + not_installed = sorted( + scope + - set(installed) + - CONFIG_WRITABLE_MCP_CLIENTS.keys() + - ({"copilot"} if vscode_only else set()) + ) if not_installed: raise RuntimeError(_not_installed_message(not_installed)) previous_workspace = current_workspace_key(load_full_state()) @@ -2019,6 +2030,10 @@ def _configure_agents_for_mcp( else: profile = None state = load_state() + if vscode_only and "copilot" not in installed and not state.get(VSCODE_CONFIGURED_KEY): + raise RuntimeError( + "VS Code isn't set up for this workspace yet. Run `ug configure --agents vscode` first." + ) host, workspace_id = split_workspace_key(workspace) state["workspace"] = host if workspace_id: @@ -2186,6 +2201,8 @@ def mcp_add( recorded = set((bucket or {}).get("available_tools") or []) | set( (bucket or {}).get("mcp_clients") or [] ) + if (bucket or {}).get(VSCODE_CONFIGURED_KEY): + recorded.add("copilot") existing_scope = ( set(configured_mcp_clients(bucket or {}, available_mcp_clients())) & recorded ) @@ -2235,7 +2252,10 @@ def mcp_remove( requested_agents = ( None if agents is None - else ({a.strip().lower() for a in agents.split(",") if a.strip()} or None) + else ( + mcp_clients_for_agents({a.strip().lower() for a in agents.split(",") if a.strip()}) + or None + ) ) try: remove_mcp_command(agents=requested_agents) @@ -2268,7 +2288,10 @@ def mcp_list( requested_agents = ( None if agents is None - else ({a.strip().lower() for a in agents.split(",") if a.strip()} or None) + else ( + mcp_clients_for_agents({a.strip().lower() for a in agents.split(",") if a.strip()}) + or None + ) ) try: list_mcp_command(agents=requested_agents) @@ -2529,7 +2552,12 @@ def skills_remove( requested_agents = ( None if agents is None - else ({a.strip().lower() for a in agents.split(",") if a.strip()} or None) + else ( + mcp_clients_for_agents( + {a.strip().lower() for a in agents.split(",") if a.strip()} + ) + or None + ) ) locations = _parse_skill_locations(location) if locations: @@ -4866,7 +4894,8 @@ def configure( state = load_state() managed, _ = _read_policy_for_other_agent(state) refuse_user_scope_over_managed_config(state.get("workspace"), managed) - vscode.configure(state, provider=provider, location=model_location) + if vscode.configure(state, provider=provider, location=model_location): + save_state({**state, VSCODE_CONFIGURED_KEY: True}) if mcp_states is not None: _restore_mcp_after_workspace_switch(previous_workspace, mcp_states[0]) elif mcp is not None: diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index 28f415485..aa0111943 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -138,6 +138,9 @@ # MCP-only clients ucode never launches for model routing, so they never land in # `available_tools`; they're eligible for MCP config purely on being installed. MCP_ONLY_CLIENTS = ("cursor",) +# Copilot Chat in VS Code reads Copilot's user MCP config, so a workspace whose state carries this +# key gets the `copilot` client even without the Copilot CLI, and `--agents vscode` names it. +VSCODE_CONFIGURED_KEY = "vscode_configured" EXTERNAL_MCP_SELECTION_PREFIX = "external:" SQL_MCP_VALUE = "managed:sql" GENIE_SPACE_SELECTION_PREFIX = "genie-space:" @@ -280,11 +283,32 @@ def _config_only_mcp_client(client: str) -> bool: return path is not None and path().is_file() +def _with_vscode_copilot(state: dict, clients: list[str]) -> list[str]: + """``clients`` plus ``copilot`` when VS Code is set up for this workspace.""" + if not state.get(VSCODE_CONFIGURED_KEY): + return clients + return [client for client in MCP_CLIENTS if client in clients or client == "copilot"] + + +def mcp_clients_for_agents(agents: set[str]) -> set[str]: + """``--agents`` names as MCP clients.""" + return {"copilot" if agent == "vscode" else agent for agent in agents} + + +def _client_display(client: str) -> str: + if client == "copilot" and not shutil.which(str(MCP_CLIENTS[client]["binary"])): + return "GitHub Copilot in VS Code" + return str(MCP_CLIENTS[client]["display"]) + + def configured_mcp_clients(state: dict, installed_clients: list[str]) -> list[str]: configured_tools = state.get("available_tools") or [] if not isinstance(configured_tools, list): configured_tools = [] configured = set(configured_tools) | set(state.get("mcp_clients") or []) + if state.get(VSCODE_CONFIGURED_KEY): + configured.add("copilot") + installed_clients = _with_vscode_copilot(state, installed_clients) return [ client for client in MCP_CLIENTS @@ -1674,7 +1698,7 @@ def restore_workspace_mcp_servers(state: dict, agents: set[str]) -> None: workspace = state.get("workspace") if not workspace: return - installed = set(available_mcp_clients()) & agents + installed = set(_with_vscode_copilot(state, available_mcp_clients())) & agents managed_names = managed_mcp_server_names(state, installed) servers = list(state.get("mcp_servers") or []) for client in sorted(installed): @@ -1773,7 +1797,7 @@ def remove_for(client: str, names: set[str]) -> None: def purge_cross_workspace_mcp_residue( state: dict, workspace: str, *, same_host_switch: bool = False ) -> None: - installed = set(available_mcp_clients()) + installed = set(_with_vscode_copilot(state, available_mcp_clients())) # Collect every removal as {client: {names}} and apply it as one batched write per client below, # rather than a CLI/config removal per server (see `_batch_remove_stale_servers`). batch_remove: dict[str, set[str]] = {} @@ -1986,7 +2010,7 @@ def setup_mcp_clients( if purge_foreign: purge_cross_workspace_mcp_residue(state, workspace) - installed_clients = available_mcp_clients() + installed_clients = _with_vscode_copilot(state, available_mcp_clients()) explicit_direct = ( set(state.get("mcp_clients") or []) & (agents or set()) & CONFIG_WRITABLE_MCP_CLIENTS.keys() ) @@ -2024,7 +2048,7 @@ def setup_mcp_clients( if not quiet: print_section(section) - client_names = ", ".join(str(MCP_CLIENTS[client]["display"]) for client in clients) + client_names = ", ".join(_client_display(client) for client in clients) print_note(f"{action_note}: {client_names}") for client in missing_clients: print_warning( @@ -2682,6 +2706,9 @@ def _run_mcp_list(client: str) -> str | None: def query_live_mcp_status(client: str) -> dict[str, str]: """MCP ``{server_name: state}`` for one client, including config-only status.""" + if client == "copilot" and not shutil.which(str(MCP_CLIENTS[client]["binary"])): + # VS Code only: no Copilot CLI to ask, so report what Copilot's user config holds. + return dict.fromkeys(copilot.user_mcp_server_names(), LIVE_ENABLED) config_only = _config_only_mcp_client(client) and not _mcp_cli_available(client) output = None if config_only else _run_mcp_list(client) statuses = parse_mcp_list_output(client, output) if output is not None else {} @@ -2878,7 +2905,7 @@ def list_mcp_command(agents: set[str] | None = None) -> int: ) state = load_state() - installed = available_mcp_clients() + installed = _with_vscode_copilot(state, available_mcp_clients()) probe_clients = [client for client in installed if agents is None or client in agents] scope_note = "" if agents is None else f" for {', '.join(sorted(agents))}" diff --git a/tests/test_agent_copilot.py b/tests/test_agent_copilot.py index f416b1ad2..f27251963 100644 --- a/tests/test_agent_copilot.py +++ b/tests/test_agent_copilot.py @@ -925,6 +925,7 @@ def run(argv, **kwargs): return subprocess.CompletedProcess(argv, 1 if stderr else 0, "", stderr) monkeypatch.setattr(copilot.subprocess_cross_os, "run", run) + monkeypatch.setattr(copilot.shutil, "which", lambda binary: f"/usr/bin/{binary}") return calls def test_defers_to_the_copilot_cli_for_a_config_it_cannot_parse(self, monkeypatch): @@ -948,6 +949,30 @@ def test_defers_to_the_copilot_cli_for_a_config_it_cannot_parse(self, monkeypatc assert path.read_text(encoding="utf-8") == jsonc assert not copilot.LEGACY_MCP_CONFIG_PATH.exists() + def test_commented_config_without_the_copilot_cli_is_left_alone(self, monkeypatch): + # VS Code alone has no Copilot CLI to edit a file ug can't parse. + calls = self._stub_cli(monkeypatch) + monkeypatch.setattr(copilot.shutil, "which", lambda binary: None) + path = copilot.copilot_mcp_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + jsonc = '{\n // mine\n "mcpServers": {"mine": {"x": 1}},\n}\n' + path.write_text(jsonc, encoding="utf-8") + + with pytest.raises(RuntimeError, match="Remove the comments"): + copilot.write_user_mcp_servers({"svc": self.ENTRY}, set()) + + assert calls == [] + assert path.read_text(encoding="utf-8") == jsonc + + def test_user_mcp_server_names_reads_the_config_without_the_cli(self): + path = copilot.copilot_mcp_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"mcpServers": {"svc": self.ENTRY}}), encoding="utf-8") + assert copilot.user_mcp_server_names() == {"svc"} + + path.write_text('{\n // mine\n "mcpServers": {"svc": {}}\n}\n', encoding="utf-8") + assert copilot.user_mcp_server_names() == set() + def test_failed_cli_add_keeps_the_legacy_side_file(self, monkeypatch): self._stub_cli(monkeypatch, fail_add=True) path = copilot.copilot_mcp_config_path() diff --git a/tests/test_agent_vscode.py b/tests/test_agent_vscode.py index a5dd7b5da..18449e975 100644 --- a/tests/test_agent_vscode.py +++ b/tests/test_agent_vscode.py @@ -16,6 +16,7 @@ from ucode.cli import app from ucode.databricks import AnthropicModelCatalog from ucode.managed_files import OS +from ucode.mcp import VSCODE_CONFIGURED_KEY # The conftest fixture stubs these out for every test; the ones here exercise the real lookups. REAL_FIND_CODE = vscode.find_code @@ -511,6 +512,24 @@ def test_configure_agents_vscode_sets_up_the_workspace_then_vscode(monkeypatch, assert ("VS Code isn't changed" in result.output) == dry_run +@pytest.mark.parametrize("installed", [True, False]) +def test_configure_agents_vscode_records_vscode_for_mcp_only_when_it_installs( + monkeypatch, installed +): + saved: list[dict] = [] + monkeypatch.setattr(config_io, "_dry_run", False) + monkeypatch.setattr("ucode.cli._configure_shared_workspace_states", lambda *a, **k: [STATE]) + monkeypatch.setattr("ucode.cli.load_state", lambda: STATE) + monkeypatch.setattr("ucode.cli.save_state", saved.append) + monkeypatch.setattr(vscode, "configure", lambda state, **_: installed) + + result = CliRunner().invoke(app, ["configure", "--agents", "vscode", "--workspace", WS]) + + assert result.exit_code == 0, result.output + assert saved == ([{**STATE, VSCODE_CONFIGURED_KEY: True}] if installed else []) + assert VSCODE_CONFIGURED_KEY not in STATE + + @pytest.mark.parametrize( ("args", "error"), [ diff --git a/tests/test_cli.py b/tests/test_cli.py index d8e30d5c1..35d2e8cbe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2505,6 +2505,17 @@ def test_mcp_list_forwards_agents_option(self, monkeypatch): assert result.exit_code == 0, result.output assert calls == [{"claude", "codex"}] + @pytest.mark.parametrize("command", ["list", "remove"]) + def test_mcp_agents_vscode_means_copilot_config(self, monkeypatch, command): + calls: list[set[str] | None] = [] + monkeypatch.setattr( + cli_mod, f"{command}_mcp_command", lambda agents=None: calls.append(agents) + ) + monkeypatch.setattr(cli_mod, "_sync_claude_cowork_plugin", lambda: None) + result = runner.invoke(app, ["mcp", command, "--agents", "vscode,claude"]) + assert result.exit_code == 0, result.output + assert calls == [{"copilot", "claude"}] + def test_mcp_add_syncs_claude_cowork(self): with ( patch("ucode.cli.add_mcp_command") as mock_add, @@ -3345,6 +3356,38 @@ def test_other_clients_without_cli_still_fail_before_prompt(self): prompt.assert_not_called() + def test_vscode_targets_copilot_config_without_the_copilot_cli(self): + state = {"workspace": "https://ws", cli_mod.VSCODE_CONFIGURED_KEY: True} + with ( + patch("ucode.cli.load_state", return_value=state), + patch("ucode.cli.available_mcp_clients", return_value=["claude"]), + patch("ucode.cli.configure_workspace_command") as mock_cfg, + ): + scope = cli_mod._configure_agents_for_mcp(["vscode"]) + + assert scope == {"copilot"} + mock_cfg.assert_not_called() + + def test_vscode_not_set_up_errors_instead_of_installing_copilot(self): + with ( + patch("ucode.cli.load_state", return_value={"workspace": "https://ws"}), + patch("ucode.cli.available_mcp_clients", return_value=[]), + patch("ucode.cli.configure_workspace_command") as mock_cfg, + pytest.raises(RuntimeError, match="ug configure --agents vscode"), + ): + cli_mod._configure_agents_for_mcp(["vscode"]) + + mock_cfg.assert_not_called() + + def test_vscode_with_copilot_still_needs_the_copilot_cli(self): + state = {"workspace": "https://ws", cli_mod.VSCODE_CONFIGURED_KEY: True} + with ( + patch("ucode.cli.load_state", return_value=state), + patch("ucode.cli.available_mcp_clients", return_value=[]), + pytest.raises(RuntimeError, match="copilot"), + ): + cli_mod._configure_agents_for_mcp(["vscode", "copilot"]) + class TestMcpAddWorkspace: def test_workspace_flag_forwarded_to_agent_setup(self): diff --git a/tests/test_mcp.py b/tests/test_mcp.py index ee64e8349..1cadd2c67 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -285,6 +285,37 @@ def test_skipped_when_binary_not_installed(self): assert "cursor" not in clients +class TestVscodeMcpClient: + STATE = {"workspace": WS, "available_tools": [], mcp.VSCODE_CONFIGURED_KEY: True} + + def test_vscode_setup_makes_copilot_eligible_without_its_cli(self): + assert mcp.configured_mcp_clients(self.STATE, ["claude"]) == ["copilot"] + + def test_copilot_still_needs_its_cli_without_a_vscode_setup(self): + assert mcp.configured_mcp_clients({"available_tools": ["copilot"]}, ["claude"]) == [] + + def test_agents_vscode_names_the_copilot_client(self): + assert mcp.mcp_clients_for_agents({"vscode", "claude"}) == {"copilot", "claude"} + + def test_setup_targets_copilot_in_vscode_when_no_agent_cli_is_installed( + self, monkeypatch, capsys + ): + monkeypatch.setattr(mcp.shutil, "which", lambda binary: None) + + _, _, clients = mcp.setup_mcp_clients(self.STATE, "MCP", require_auth=False) + + assert clients == ["copilot"] + assert "GitHub Copilot in VS Code" in _unwrap(capsys.readouterr().out) + + def test_list_reads_copilot_config_when_its_cli_is_missing(self, monkeypatch): + monkeypatch.setattr(mcp.shutil, "which", lambda binary: None) + path = mcp.copilot.copilot_mcp_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"mcpServers": {"svc": {"type": "local"}}}), encoding="utf-8") + + assert mcp.query_live_mcp_status("copilot") == {"svc": mcp.LIVE_ENABLED} + + class TestKiloMcpClient: def test_kilo_registered_in_mcp_clients(self): assert "kilo" in mcp.MCP_CLIENTS @@ -3290,6 +3321,34 @@ def test_removes_copilot_servers_from_its_user_config_and_the_legacy_side_file(s assert json.loads(user_config.read_text())["mcpServers"] == {"mine": {"x": 1}} assert not legacy.exists() + def test_commented_copilot_config_without_its_cli_does_not_stop_the_revert( + self, monkeypatch, capsys + ): + monkeypatch.setattr(mcp.copilot.shutil, "which", lambda binary: None) + user_config = mcp.copilot.copilot_mcp_config_path() + user_config.parent.mkdir(parents=True, exist_ok=True) + jsonc = '{\n // mine\n "mcpServers": {"svc": {"type": "local"}}\n}\n' + user_config.write_text(jsonc, encoding="utf-8") + removed: list[tuple[str, str]] = [] + real_remove = mcp.remove_client_mcp_server + + def remove(client, name): + if client == "copilot": + return real_remove(client, name) + removed.append((client, name)) + return ["user"] + + monkeypatch.setattr(mcp, "remove_client_mcp_server", remove) + + result = mcp.revert_mcp_configs( + {"mcp_servers": [{"name": "svc", "clients": ["copilot", "claude"]}]} + ) + + assert removed == [("claude", "svc")] + assert result == {"claude": True} + assert user_config.read_text(encoding="utf-8") == jsonc + assert "Remove the comments" in _unwrap(capsys.readouterr().out) + def test_removes_skills_registry_across_its_clients(self, monkeypatch): removed: list[tuple[str, str]] = [] monkeypatch.setattr( From 08015ac79a6bfb8ccfbb9091b5dba455c514d408 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Fri, 30 Oct 2026 13:59:38 +0000 Subject: [PATCH 06/18] copilot: use AGENT_COPILOT for the VS Code MCP checks in cli.py Co-authored-by: Isaac --- src/ucode/cli.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index afe2be059..874cf560d 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -2007,12 +2007,12 @@ def _configure_agents_for_mcp( # A named client whose user MCP config UG writes directly needs no CLI on PATH. VS Code reads # Copilot's user config, so `vscode` alone needs a VS Code setup for the workspace rather than # the Copilot CLI; that's checked once the workspace is selected. - vscode_only = "vscode" in names and "copilot" not in names + vscode_only = "vscode" in names and AGENT_COPILOT not in names not_installed = sorted( scope - set(installed) - CONFIG_WRITABLE_MCP_CLIENTS.keys() - - ({"copilot"} if vscode_only else set()) + - ({AGENT_COPILOT} if vscode_only else set()) ) if not_installed: raise RuntimeError(_not_installed_message(not_installed)) @@ -2030,7 +2030,7 @@ def _configure_agents_for_mcp( else: profile = None state = load_state() - if vscode_only and "copilot" not in installed and not state.get(VSCODE_CONFIGURED_KEY): + if vscode_only and AGENT_COPILOT not in installed and not state.get(VSCODE_CONFIGURED_KEY): raise RuntimeError( "VS Code isn't set up for this workspace yet. Run `ug configure --agents vscode` first." ) @@ -2202,7 +2202,7 @@ def mcp_add( (bucket or {}).get("mcp_clients") or [] ) if (bucket or {}).get(VSCODE_CONFIGURED_KEY): - recorded.add("copilot") + recorded.add(AGENT_COPILOT) existing_scope = ( set(configured_mcp_clients(bucket or {}, available_mcp_clients())) & recorded ) From 85d588c15a67e7ae8d8d3a3e15aec203760f923c Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Fri, 30 Oct 2026 14:01:22 +0000 Subject: [PATCH 07/18] copilot: add AGENT_CURSOR and AGENT_VSCODE for the MCP agent checks Co-authored-by: Isaac --- src/ucode/cli.py | 6 ++++-- src/ucode/constants.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 874cf560d..0e2ec30c7 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -59,6 +59,8 @@ AGENT_CLAUDE, AGENT_CODEX, AGENT_COPILOT, + AGENT_CURSOR, + AGENT_VSCODE, ISAAC_SMART_ROUTER_FEEDBACK_ENV_VAR, SMART_ROUTING_ENV_KEYS, UG_HEADERS_ENV_VAR, @@ -2001,13 +2003,13 @@ def _configure_agents_for_mcp( workspace_entries: list[tuple[str, str | None]] | None = None, ) -> set[str]: """Select MCP clients for one workspace without changing agent model settings.""" - names = {a if a in ("cursor", "vscode") else normalize_tool(a) for a in requested} + names = {a if a in (AGENT_CURSOR, AGENT_VSCODE) else normalize_tool(a) for a in requested} scope = mcp_clients_for_agents(names) installed = available_mcp_clients() # A named client whose user MCP config UG writes directly needs no CLI on PATH. VS Code reads # Copilot's user config, so `vscode` alone needs a VS Code setup for the workspace rather than # the Copilot CLI; that's checked once the workspace is selected. - vscode_only = "vscode" in names and AGENT_COPILOT not in names + vscode_only = AGENT_VSCODE in names and AGENT_COPILOT not in names not_installed = sorted( scope - set(installed) diff --git a/src/ucode/constants.py b/src/ucode/constants.py index db9b2ea0f..93a42eb35 100644 --- a/src/ucode/constants.py +++ b/src/ucode/constants.py @@ -8,6 +8,8 @@ AGENT_CLAUDE = "claude" AGENT_CODEX = "codex" AGENT_COPILOT = "copilot" +AGENT_CURSOR = "cursor" +AGENT_VSCODE = "vscode" ENABLE_SMART_ROUTING_ENV_VAR = "ENABLE_SMART_ROUTING_V2" ENABLE_SUBAGENT_ROUTING_ENV_VAR = "ENABLE_SMART_ROUTING_SUBAGENT_ONLY" From 724a3789ab332a33467acb4b333bb3296260ddd7 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Fri, 30 Oct 2026 14:17:28 +0000 Subject: [PATCH 08/18] copilot: label VS Code-only setups in mcp list and revert; drop an emptied Copilot MCP config For a workspace set up for VS Code with no Copilot CLI, ug mcp list's note, ug revert's summary row and its removal warning now say "GitHub Copilot in VS Code" instead of "GitHub Copilot CLI". The label needs the VS Code marker, so a machine that simply lacks the Copilot CLI keeps the old name. When a removal leaves Copilot's user config with nothing but an empty mcpServers, ug deletes the file instead of leaving {"mcpServers": {}} behind. Copilot reads a missing file the same way; a config with any other key is rewritten as before. Co-authored-by: Isaac --- src/ucode/agents/copilot.py | 10 +++++++++- src/ucode/cli.py | 5 +++-- src/ucode/mcp.py | 14 +++++++++----- tests/test_agent_copilot.py | 18 ++++++++++++++++++ tests/test_mcp.py | 15 +++++++++++++++ 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/ucode/agents/copilot.py b/src/ucode/agents/copilot.py index 689055b98..4d34efe50 100644 --- a/src/ucode/agents/copilot.py +++ b/src/ucode/agents/copilot.py @@ -913,7 +913,15 @@ def _apply_mcp_diff(add: dict[str, dict], remove: set[str]) -> set[str]: servers.pop(name, None) servers.update(upserts) config["mcpServers"] = servers - write_json_file(path, config) + if config == {"mcpServers": {}}: + # Copilot reads a missing file the same way, so don't leave an empty one behind. + if not is_dry_run(): + try: + path.unlink(missing_ok=True) + except OSError as exc: + raise RuntimeError(f"Failed to remove {path}") from exc + else: + write_json_file(path, config) remove_legacy_mcp_config() return removed diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 0e2ec30c7..31f883e40 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -155,6 +155,7 @@ configured_mcp_clients, configured_mcp_servers_by_name, list_mcp_command, + mcp_client_display, mcp_clients_for_agents, purge_cross_workspace_mcp_residue, reconcile_managed_mcp_servers, @@ -1721,9 +1722,9 @@ def revert() -> int: print_kv("Unity Gateway workspace policy", workspace_policy_result) print_kv("Pi settings", "restored" if pi_settings_restored else "unchanged") print_kv("VS Code extension", vscode_result) - for client, spec in MCP_CLIENTS.items(): + for client in MCP_CLIENTS: print_kv( - f"{spec['display']} MCP config", + f"{mcp_client_display(client, state)} MCP config", "restored" if mcp_results.get(client) else "unchanged", ) if desktop_result == "failed": diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index aa0111943..022cbc8f0 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -295,8 +295,12 @@ def mcp_clients_for_agents(agents: set[str]) -> set[str]: return {"copilot" if agent == "vscode" else agent for agent in agents} -def _client_display(client: str) -> str: - if client == "copilot" and not shutil.which(str(MCP_CLIENTS[client]["binary"])): +def mcp_client_display(client: str, state: dict) -> str: + if ( + client == "copilot" + and state.get(VSCODE_CONFIGURED_KEY) + and not shutil.which(str(MCP_CLIENTS[client]["binary"])) + ): return "GitHub Copilot in VS Code" return str(MCP_CLIENTS[client]["display"]) @@ -538,7 +542,7 @@ def revert_mcp_configs(state: dict) -> dict[str, bool]: try: removed_scopes = remove_client_mcp_server(client, name) except RuntimeError as exc: - display = MCP_CLIENTS[client]["display"] + display = mcp_client_display(client, state) print_warning(f"Could not remove MCP server `{name}` from {display}: {exc}") continue results[client] = bool(removed_scopes) or results.get(client, False) @@ -2048,7 +2052,7 @@ def setup_mcp_clients( if not quiet: print_section(section) - client_names = ", ".join(_client_display(client) for client in clients) + client_names = ", ".join(mcp_client_display(client, state) for client in clients) print_note(f"{action_note}: {client_names}") for client in missing_clients: print_warning( @@ -2969,7 +2973,7 @@ def list_mcp_command(agents: set[str] | None = None) -> int: ) # Only mention the enabled/disabled caveat for the agents actually probed through their CLI. config_state_clients = [ - MCP_CLIENTS[client]["display"] + mcp_client_display(client, state) for client in ("codex", "copilot") if client in probe_clients and client not in config_only ] diff --git a/tests/test_agent_copilot.py b/tests/test_agent_copilot.py index f27251963..0315d088d 100644 --- a/tests/test_agent_copilot.py +++ b/tests/test_agent_copilot.py @@ -973,6 +973,24 @@ def test_user_mcp_server_names_reads_the_config_without_the_cli(self): path.write_text('{\n // mine\n "mcpServers": {"svc": {}}\n}\n', encoding="utf-8") assert copilot.user_mcp_server_names() == set() + def test_removing_the_last_server_deletes_a_config_with_nothing_else(self): + path = copilot.copilot_mcp_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"mcpServers": {"svc": self.ENTRY}}), encoding="utf-8") + + assert copilot.write_user_mcp_servers({}, {"svc"}) == {"svc"} + + assert not path.exists() + + def test_removing_the_last_server_keeps_a_config_with_other_keys(self): + path = copilot.copilot_mcp_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"mcpServers": {"svc": self.ENTRY}, "x": 1}), encoding="utf-8") + + copilot.write_user_mcp_servers({}, {"svc"}) + + assert json.loads(path.read_text(encoding="utf-8")) == {"mcpServers": {}, "x": 1} + def test_failed_cli_add_keeps_the_legacy_side_file(self, monkeypatch): self._stub_cli(monkeypatch, fail_add=True) path = copilot.copilot_mcp_config_path() diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 1cadd2c67..7291925c9 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -315,6 +315,21 @@ def test_list_reads_copilot_config_when_its_cli_is_missing(self, monkeypatch): assert mcp.query_live_mcp_status("copilot") == {"svc": mcp.LIVE_ENABLED} + @pytest.mark.parametrize( + ("vscode", "cli", "label"), + [ + (True, None, "GitHub Copilot in VS Code"), + (True, "/usr/bin/copilot", "GitHub Copilot CLI"), + (False, None, "GitHub Copilot CLI"), + ], + ) + def test_copilot_is_labeled_vscode_only_for_a_vscode_setup_without_the_cli( + self, monkeypatch, vscode, cli, label + ): + monkeypatch.setattr(mcp.shutil, "which", lambda binary: cli) + state = {mcp.VSCODE_CONFIGURED_KEY: True} if vscode else {} + assert mcp.mcp_client_display("copilot", state) == label + class TestKiloMcpClient: def test_kilo_registered_in_mcp_clients(self): From 52802bf9dae65f930fc253f222c5d335478b5139 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Fri, 30 Oct 2026 14:19:44 +0000 Subject: [PATCH 09/18] copilot: use AGENT_COPILOT and AGENT_VSCODE for agent ids in cli.py and mcp.py Command names (the copilot subcommand and the help order) and the copilot binary name stay string literals. Co-authored-by: Isaac --- src/ucode/cli.py | 36 ++++++++++++++++++++---------------- src/ucode/mcp.py | 28 ++++++++++++++-------------- 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 31f883e40..c01b38f7f 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -246,8 +246,8 @@ CustomOAuthConfig = custom_oauth.CustomOAuthConfig _DISCOVERY_CONSUMERS: dict[str, tuple[str, ...]] = { - "claude": ("claude", "opencode", "kilo", "copilot", "pi"), - "codex": ("codex", "copilot", "pi"), + "claude": ("claude", "opencode", "kilo", AGENT_COPILOT, "pi"), + "codex": ("codex", AGENT_COPILOT, "pi"), "gemini": ("gemini", "opencode", "kilo", "pi", "antigravity"), "oss": ("opencode", "kilo"), } @@ -749,7 +749,7 @@ def configure_shared_state( or "claude" in tools or "opencode" in tools or "kilo" in tools - or "copilot" in tools + or AGENT_COPILOT in tools or "pi" in tools ) want_gemini = ( @@ -762,12 +762,16 @@ def configure_shared_state( ) # Claude's web-search server also needs a Responses-capable model. want_codex = ( - fetch_all or "codex" in tools or "claude" in tools or "copilot" in tools or "pi" in tools + fetch_all + or "codex" in tools + or "claude" in tools + or AGENT_COPILOT in tools + or "pi" in tools ) # Codex smart routing can select OSS models such as GLM, so a Codex-only # configure must persist that discovered family too. want_oss = fetch_all or "opencode" in tools or "kilo" in tools or "codex" in tools - want_copilot = fetch_all or "copilot" in tools + want_copilot = fetch_all or AGENT_COPILOT in tools claude_reason: str | None = None gemini_reason: str | None = None @@ -864,7 +868,7 @@ def configure_shared_state( "gemini": gemini_reason, "codex": codex_reason, "oss": oss_reason, - "copilot": copilot_reason, + AGENT_COPILOT: copilot_reason, } return state @@ -1089,7 +1093,7 @@ def _maybe_select_provider_service(tool: str, state: dict) -> dict: back to Databricks when no matching provider services are found or the listing fails and no custom location is offered. """ - if tool not in ("claude", "codex", "gemini", "antigravity", "copilot"): + if tool not in ("claude", "codex", "gemini", "antigravity", AGENT_COPILOT): return state display = TOOL_SPECS[tool]["display"] @@ -1449,7 +1453,7 @@ def _model_values(value: object) -> list[str]: def _status_models(tool: str, state: dict) -> list[str]: """Return the effective model allow-list for one configured agent.""" static_models = _model_values(state.get(f"{tool}_static_models")) - if tool == "copilot": + if tool == AGENT_COPILOT: models = [model.id for model in copilot_agent.copilot_catalog(state)] elif static_models: models = static_models @@ -1472,7 +1476,7 @@ def _status_models(tool: str, state: dict) -> list[str]: def _status_default_model(tool: str, state: dict, models: list[str]) -> str | None: - if tool == "copilot": + if tool == AGENT_COPILOT: return copilot_agent.default_model(state) explicit = state.get(f"{tool}_default_model") if isinstance(explicit, str) and explicit: @@ -1528,7 +1532,7 @@ def _live_status_model_state(state: dict, tools: set[str]) -> tuple[dict, str]: if oss_models: opencode_models["oss"] = oss_models live["opencode_models"] = opencode_models - if "copilot" in tools: + if AGENT_COPILOT in tools: copilot_models = copilot_agent.catalog_from_listings( cached_model_service_entries(workspace), [*claude_models.values(), *codex_models], @@ -3410,7 +3414,7 @@ def _child_owns_stdout(tool: str, tool_args: list[str]) -> bool: tool_args = tool_args[: tool_args.index("--")] if tool == "claude": return any(arg in {"-p", "--print"} for arg in tool_args) - if tool == "copilot": + if tool == AGENT_COPILOT: return any(arg in {"-p", "--prompt"} or arg.startswith("--prompt=") for arg in tool_args) return tool == "codex" and any(arg in {"exec", "e", "app-server"} for arg in tool_args) @@ -3490,7 +3494,7 @@ def _skip_model_discovery( (``family_defaults``). In the last case it is also harmful: discovered family defaults would overwrite that writer's on every launch, and each rewrite of the managed file costs a sudo prompt. """ - if tool == "copilot": + if tool == AGENT_COPILOT: # Copilot registers its discovered catalog, so a managed default alone still needs it. return bool( provider or managed_parent_schema or (managed and managed_static_models(managed, tool)) @@ -4015,7 +4019,7 @@ def _launch_tool( provider=provider, # Copilot picks its start model from its own catalog; the admin's or budget pick leads. # session_model is set for claude only. - launch_model=managed_model if tool == "copilot" else session_model, + launch_model=managed_model if tool == AGENT_COPILOT else session_model, ) print_success(f"Starting {TOOL_SPECS[tool]['display']}") with _smart_routing_v2_flag(True if managed_smart_routing_applies else None, agent=tool): @@ -4489,7 +4493,7 @@ def copilot_cmd( ) -> None: """Launch GitHub Copilot CLI via Databricks.""" _launch_tool( - "copilot", + AGENT_COPILOT, ctx, provider=provider, skip_preflight=skip_preflight, @@ -4789,8 +4793,8 @@ def configure( requested_agents = [a.strip().lower() for a in (agents or "").split(",") if a.strip()] # Both flags choose VS Code's models alone, so another model agent in the same run # would silently keep the workspace's own. Cursor has no models to choose. - if "vscode" not in requested_agents or [ - a for a in requested_agents if a not in ("vscode", "cursor") + if AGENT_VSCODE not in requested_agents or [ + a for a in requested_agents if a not in (AGENT_VSCODE, "cursor") ]: raise RuntimeError( "--provider and --model-location work only with `--agents vscode`. " diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index 022cbc8f0..cc97f211d 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -19,7 +19,7 @@ from ucode.agents import antigravity, claude, codex, copilot, cursor, gemini, kilo, opencode from ucode.config_io import read_json_safe, read_toml_safe -from ucode.constants import MCP_CLEANUP_SCOPES, MCP_USER_SCOPE +from ucode.constants import AGENT_COPILOT, AGENT_VSCODE, MCP_CLEANUP_SCOPES, MCP_USER_SCOPE from ucode.databricks import ( AIGW_MCP_SERVICES_SEGMENT, AuthTokenError, @@ -110,7 +110,7 @@ "display": "Kilo", "list_command": "kilo mcp list", }, - "copilot": { + AGENT_COPILOT: { "binary": "copilot", "display": "GitHub Copilot CLI", "list_command": "copilot mcp list --json", @@ -287,17 +287,17 @@ def _with_vscode_copilot(state: dict, clients: list[str]) -> list[str]: """``clients`` plus ``copilot`` when VS Code is set up for this workspace.""" if not state.get(VSCODE_CONFIGURED_KEY): return clients - return [client for client in MCP_CLIENTS if client in clients or client == "copilot"] + return [client for client in MCP_CLIENTS if client in clients or client == AGENT_COPILOT] def mcp_clients_for_agents(agents: set[str]) -> set[str]: """``--agents`` names as MCP clients.""" - return {"copilot" if agent == "vscode" else agent for agent in agents} + return {AGENT_COPILOT if agent == AGENT_VSCODE else agent for agent in agents} def mcp_client_display(client: str, state: dict) -> str: if ( - client == "copilot" + client == AGENT_COPILOT and state.get(VSCODE_CONFIGURED_KEY) and not shutil.which(str(MCP_CLIENTS[client]["binary"])) ): @@ -311,7 +311,7 @@ def configured_mcp_clients(state: dict, installed_clients: list[str]) -> list[st configured_tools = [] configured = set(configured_tools) | set(state.get("mcp_clients") or []) if state.get(VSCODE_CONFIGURED_KEY): - configured.add("copilot") + configured.add(AGENT_COPILOT) installed_clients = _with_vscode_copilot(state, installed_clients) return [ client @@ -482,7 +482,7 @@ def configure_client_mcp_server( if client == "kilo": removed = kilo.write_mcp_server_config(name, argv) return [MCP_USER_SCOPE] if removed else [] - if client == "copilot": + if client == AGENT_COPILOT: removed = copilot.write_mcp_server_config(name, argv) return [MCP_USER_SCOPE] if removed else [] if client == "cursor": @@ -510,7 +510,7 @@ def remove_client_mcp_server(client: str, name: str) -> list[str]: return [MCP_USER_SCOPE] if opencode.remove_mcp_server_config(name) else [] if client == "kilo": return [MCP_USER_SCOPE] if kilo.remove_mcp_server_config(name) else [] - if client == "copilot": + if client == AGENT_COPILOT: return [MCP_USER_SCOPE] if copilot.remove_mcp_server_config(name) else [] if client == "cursor": return [MCP_USER_SCOPE] if cursor.remove_mcp_server_config(name) else [] @@ -529,7 +529,7 @@ def revert_mcp_configs(state: dict) -> dict[str, bool]: # One agent's unreadable config must not stop the revert of the others. try: if copilot.remove_legacy_mcp_config(): - results["copilot"] = True + results[AGENT_COPILOT] = True except RuntimeError as exc: print_warning(f"Could not remove ug's old Copilot MCP file: {exc}") for server in all_servers: @@ -1522,7 +1522,7 @@ def _discover_selected_mcp_sources( "claude": claude, "codex": codex, "gemini": gemini, - "copilot": copilot, + AGENT_COPILOT: copilot, "cursor": cursor, "opencode": opencode, "kilo": kilo, @@ -1566,7 +1566,7 @@ def _managed_mcp_entry( return cursor.build_mcp_server_entry(argv) if client == "gemini": return gemini.build_mcp_server_entry(argv) - if client == "copilot": + if client == AGENT_COPILOT: return copilot.build_mcp_server_entry(argv) if client == "opencode": return opencode.build_mcp_server_entry(argv) @@ -2664,7 +2664,7 @@ def parse_mcp_list_output(client: str, output: str) -> dict[str, str]: if client in ("codex", "antigravity"): # Both print a columnar table with an enabled/disabled state column. parsed = _parse_codex_mcp_list(output) - elif client == "copilot": + elif client == AGENT_COPILOT: parsed = _parse_copilot_mcp_list(output) elif client in ("kilo", "opencode"): parsed = _parse_tree_mcp_list(output) @@ -2710,7 +2710,7 @@ def _run_mcp_list(client: str) -> str | None: def query_live_mcp_status(client: str) -> dict[str, str]: """MCP ``{server_name: state}`` for one client, including config-only status.""" - if client == "copilot" and not shutil.which(str(MCP_CLIENTS[client]["binary"])): + if client == AGENT_COPILOT and not shutil.which(str(MCP_CLIENTS[client]["binary"])): # VS Code only: no Copilot CLI to ask, so report what Copilot's user config holds. return dict.fromkeys(copilot.user_mcp_server_names(), LIVE_ENABLED) config_only = _config_only_mcp_client(client) and not _mcp_cli_available(client) @@ -2974,7 +2974,7 @@ def list_mcp_command(agents: set[str] | None = None) -> int: # Only mention the enabled/disabled caveat for the agents actually probed through their CLI. config_state_clients = [ mcp_client_display(client, state) - for client in ("codex", "copilot") + for client in ("codex", AGENT_COPILOT) if client in probe_clients and client not in config_only ] if config_state_clients: From 123f6e607918554a7506a0ff2d8dbf1f5fd29b60 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Fri, 30 Oct 2026 14:21:43 +0000 Subject: [PATCH 10/18] Revert "copilot: use AGENT_COPILOT and AGENT_VSCODE for agent ids in cli.py and mcp.py" This reverts commit 48cf89c96fb5120878b40b2178d87d0f9a771b91. Co-authored-by: Isaac --- src/ucode/cli.py | 36 ++++++++++++++++-------------------- src/ucode/mcp.py | 28 ++++++++++++++-------------- 2 files changed, 30 insertions(+), 34 deletions(-) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index c01b38f7f..31f883e40 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -246,8 +246,8 @@ CustomOAuthConfig = custom_oauth.CustomOAuthConfig _DISCOVERY_CONSUMERS: dict[str, tuple[str, ...]] = { - "claude": ("claude", "opencode", "kilo", AGENT_COPILOT, "pi"), - "codex": ("codex", AGENT_COPILOT, "pi"), + "claude": ("claude", "opencode", "kilo", "copilot", "pi"), + "codex": ("codex", "copilot", "pi"), "gemini": ("gemini", "opencode", "kilo", "pi", "antigravity"), "oss": ("opencode", "kilo"), } @@ -749,7 +749,7 @@ def configure_shared_state( or "claude" in tools or "opencode" in tools or "kilo" in tools - or AGENT_COPILOT in tools + or "copilot" in tools or "pi" in tools ) want_gemini = ( @@ -762,16 +762,12 @@ def configure_shared_state( ) # Claude's web-search server also needs a Responses-capable model. want_codex = ( - fetch_all - or "codex" in tools - or "claude" in tools - or AGENT_COPILOT in tools - or "pi" in tools + fetch_all or "codex" in tools or "claude" in tools or "copilot" in tools or "pi" in tools ) # Codex smart routing can select OSS models such as GLM, so a Codex-only # configure must persist that discovered family too. want_oss = fetch_all or "opencode" in tools or "kilo" in tools or "codex" in tools - want_copilot = fetch_all or AGENT_COPILOT in tools + want_copilot = fetch_all or "copilot" in tools claude_reason: str | None = None gemini_reason: str | None = None @@ -868,7 +864,7 @@ def configure_shared_state( "gemini": gemini_reason, "codex": codex_reason, "oss": oss_reason, - AGENT_COPILOT: copilot_reason, + "copilot": copilot_reason, } return state @@ -1093,7 +1089,7 @@ def _maybe_select_provider_service(tool: str, state: dict) -> dict: back to Databricks when no matching provider services are found or the listing fails and no custom location is offered. """ - if tool not in ("claude", "codex", "gemini", "antigravity", AGENT_COPILOT): + if tool not in ("claude", "codex", "gemini", "antigravity", "copilot"): return state display = TOOL_SPECS[tool]["display"] @@ -1453,7 +1449,7 @@ def _model_values(value: object) -> list[str]: def _status_models(tool: str, state: dict) -> list[str]: """Return the effective model allow-list for one configured agent.""" static_models = _model_values(state.get(f"{tool}_static_models")) - if tool == AGENT_COPILOT: + if tool == "copilot": models = [model.id for model in copilot_agent.copilot_catalog(state)] elif static_models: models = static_models @@ -1476,7 +1472,7 @@ def _status_models(tool: str, state: dict) -> list[str]: def _status_default_model(tool: str, state: dict, models: list[str]) -> str | None: - if tool == AGENT_COPILOT: + if tool == "copilot": return copilot_agent.default_model(state) explicit = state.get(f"{tool}_default_model") if isinstance(explicit, str) and explicit: @@ -1532,7 +1528,7 @@ def _live_status_model_state(state: dict, tools: set[str]) -> tuple[dict, str]: if oss_models: opencode_models["oss"] = oss_models live["opencode_models"] = opencode_models - if AGENT_COPILOT in tools: + if "copilot" in tools: copilot_models = copilot_agent.catalog_from_listings( cached_model_service_entries(workspace), [*claude_models.values(), *codex_models], @@ -3414,7 +3410,7 @@ def _child_owns_stdout(tool: str, tool_args: list[str]) -> bool: tool_args = tool_args[: tool_args.index("--")] if tool == "claude": return any(arg in {"-p", "--print"} for arg in tool_args) - if tool == AGENT_COPILOT: + if tool == "copilot": return any(arg in {"-p", "--prompt"} or arg.startswith("--prompt=") for arg in tool_args) return tool == "codex" and any(arg in {"exec", "e", "app-server"} for arg in tool_args) @@ -3494,7 +3490,7 @@ def _skip_model_discovery( (``family_defaults``). In the last case it is also harmful: discovered family defaults would overwrite that writer's on every launch, and each rewrite of the managed file costs a sudo prompt. """ - if tool == AGENT_COPILOT: + if tool == "copilot": # Copilot registers its discovered catalog, so a managed default alone still needs it. return bool( provider or managed_parent_schema or (managed and managed_static_models(managed, tool)) @@ -4019,7 +4015,7 @@ def _launch_tool( provider=provider, # Copilot picks its start model from its own catalog; the admin's or budget pick leads. # session_model is set for claude only. - launch_model=managed_model if tool == AGENT_COPILOT else session_model, + launch_model=managed_model if tool == "copilot" else session_model, ) print_success(f"Starting {TOOL_SPECS[tool]['display']}") with _smart_routing_v2_flag(True if managed_smart_routing_applies else None, agent=tool): @@ -4493,7 +4489,7 @@ def copilot_cmd( ) -> None: """Launch GitHub Copilot CLI via Databricks.""" _launch_tool( - AGENT_COPILOT, + "copilot", ctx, provider=provider, skip_preflight=skip_preflight, @@ -4793,8 +4789,8 @@ def configure( requested_agents = [a.strip().lower() for a in (agents or "").split(",") if a.strip()] # Both flags choose VS Code's models alone, so another model agent in the same run # would silently keep the workspace's own. Cursor has no models to choose. - if AGENT_VSCODE not in requested_agents or [ - a for a in requested_agents if a not in (AGENT_VSCODE, "cursor") + if "vscode" not in requested_agents or [ + a for a in requested_agents if a not in ("vscode", "cursor") ]: raise RuntimeError( "--provider and --model-location work only with `--agents vscode`. " diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index cc97f211d..022cbc8f0 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -19,7 +19,7 @@ from ucode.agents import antigravity, claude, codex, copilot, cursor, gemini, kilo, opencode from ucode.config_io import read_json_safe, read_toml_safe -from ucode.constants import AGENT_COPILOT, AGENT_VSCODE, MCP_CLEANUP_SCOPES, MCP_USER_SCOPE +from ucode.constants import MCP_CLEANUP_SCOPES, MCP_USER_SCOPE from ucode.databricks import ( AIGW_MCP_SERVICES_SEGMENT, AuthTokenError, @@ -110,7 +110,7 @@ "display": "Kilo", "list_command": "kilo mcp list", }, - AGENT_COPILOT: { + "copilot": { "binary": "copilot", "display": "GitHub Copilot CLI", "list_command": "copilot mcp list --json", @@ -287,17 +287,17 @@ def _with_vscode_copilot(state: dict, clients: list[str]) -> list[str]: """``clients`` plus ``copilot`` when VS Code is set up for this workspace.""" if not state.get(VSCODE_CONFIGURED_KEY): return clients - return [client for client in MCP_CLIENTS if client in clients or client == AGENT_COPILOT] + return [client for client in MCP_CLIENTS if client in clients or client == "copilot"] def mcp_clients_for_agents(agents: set[str]) -> set[str]: """``--agents`` names as MCP clients.""" - return {AGENT_COPILOT if agent == AGENT_VSCODE else agent for agent in agents} + return {"copilot" if agent == "vscode" else agent for agent in agents} def mcp_client_display(client: str, state: dict) -> str: if ( - client == AGENT_COPILOT + client == "copilot" and state.get(VSCODE_CONFIGURED_KEY) and not shutil.which(str(MCP_CLIENTS[client]["binary"])) ): @@ -311,7 +311,7 @@ def configured_mcp_clients(state: dict, installed_clients: list[str]) -> list[st configured_tools = [] configured = set(configured_tools) | set(state.get("mcp_clients") or []) if state.get(VSCODE_CONFIGURED_KEY): - configured.add(AGENT_COPILOT) + configured.add("copilot") installed_clients = _with_vscode_copilot(state, installed_clients) return [ client @@ -482,7 +482,7 @@ def configure_client_mcp_server( if client == "kilo": removed = kilo.write_mcp_server_config(name, argv) return [MCP_USER_SCOPE] if removed else [] - if client == AGENT_COPILOT: + if client == "copilot": removed = copilot.write_mcp_server_config(name, argv) return [MCP_USER_SCOPE] if removed else [] if client == "cursor": @@ -510,7 +510,7 @@ def remove_client_mcp_server(client: str, name: str) -> list[str]: return [MCP_USER_SCOPE] if opencode.remove_mcp_server_config(name) else [] if client == "kilo": return [MCP_USER_SCOPE] if kilo.remove_mcp_server_config(name) else [] - if client == AGENT_COPILOT: + if client == "copilot": return [MCP_USER_SCOPE] if copilot.remove_mcp_server_config(name) else [] if client == "cursor": return [MCP_USER_SCOPE] if cursor.remove_mcp_server_config(name) else [] @@ -529,7 +529,7 @@ def revert_mcp_configs(state: dict) -> dict[str, bool]: # One agent's unreadable config must not stop the revert of the others. try: if copilot.remove_legacy_mcp_config(): - results[AGENT_COPILOT] = True + results["copilot"] = True except RuntimeError as exc: print_warning(f"Could not remove ug's old Copilot MCP file: {exc}") for server in all_servers: @@ -1522,7 +1522,7 @@ def _discover_selected_mcp_sources( "claude": claude, "codex": codex, "gemini": gemini, - AGENT_COPILOT: copilot, + "copilot": copilot, "cursor": cursor, "opencode": opencode, "kilo": kilo, @@ -1566,7 +1566,7 @@ def _managed_mcp_entry( return cursor.build_mcp_server_entry(argv) if client == "gemini": return gemini.build_mcp_server_entry(argv) - if client == AGENT_COPILOT: + if client == "copilot": return copilot.build_mcp_server_entry(argv) if client == "opencode": return opencode.build_mcp_server_entry(argv) @@ -2664,7 +2664,7 @@ def parse_mcp_list_output(client: str, output: str) -> dict[str, str]: if client in ("codex", "antigravity"): # Both print a columnar table with an enabled/disabled state column. parsed = _parse_codex_mcp_list(output) - elif client == AGENT_COPILOT: + elif client == "copilot": parsed = _parse_copilot_mcp_list(output) elif client in ("kilo", "opencode"): parsed = _parse_tree_mcp_list(output) @@ -2710,7 +2710,7 @@ def _run_mcp_list(client: str) -> str | None: def query_live_mcp_status(client: str) -> dict[str, str]: """MCP ``{server_name: state}`` for one client, including config-only status.""" - if client == AGENT_COPILOT and not shutil.which(str(MCP_CLIENTS[client]["binary"])): + if client == "copilot" and not shutil.which(str(MCP_CLIENTS[client]["binary"])): # VS Code only: no Copilot CLI to ask, so report what Copilot's user config holds. return dict.fromkeys(copilot.user_mcp_server_names(), LIVE_ENABLED) config_only = _config_only_mcp_client(client) and not _mcp_cli_available(client) @@ -2974,7 +2974,7 @@ def list_mcp_command(agents: set[str] | None = None) -> int: # Only mention the enabled/disabled caveat for the agents actually probed through their CLI. config_state_clients = [ mcp_client_display(client, state) - for client in ("codex", AGENT_COPILOT) + for client in ("codex", "copilot") if client in probe_clients and client not in config_only ] if config_state_clients: From b3f5fb664aff088823d83fa0edb71b4526fbafd2 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Fri, 30 Oct 2026 15:30:56 +0000 Subject: [PATCH 11/18] copilot: keep a copy of a commented MCP config before the Copilot CLI rewrites it When ug can't parse Copilot's user config (comments, trailing commas) it edits it through `copilot mcp add/remove`. The real CLI saves the file as plain JSON, so the comments are lost and it adds "tools": ["*"] to the developer's own entries. Before the first CLI-driven edit ug now keeps the original at ~/.ucode/copilot-mcp-config.original.json, once, and says so. The unit test asserted the file kept its comments, which only held because the CLI was stubbed; it now checks the copy and the note. Co-authored-by: Isaac --- src/ucode/agents/copilot.py | 12 ++++++++++-- tests/conftest.py | 3 +++ tests/test_agent_copilot.py | 34 ++++++++++++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/ucode/agents/copilot.py b/src/ucode/agents/copilot.py index 4d34efe50..f2737bdca 100644 --- a/src/ucode/agents/copilot.py +++ b/src/ucode/agents/copilot.py @@ -83,7 +83,7 @@ from ucode.smart_routing.routing import unwrap_anthropic_gateway_model from ucode.state import is_tool_managed, mark_tool_managed, save_state from ucode.telemetry import agent_version, ucode_user_agent, ug_version -from ucode.ui import print_warning +from ucode.ui import print_note, print_warning from .args import LaunchOptions, explicit_model_arg_value from .request_tags import request_tags_header_value @@ -95,6 +95,7 @@ # Older ug versions kept their servers in a side file loaded with `--additional-mcp-config`. LEGACY_MCP_CONFIG_PATH = COPILOT_CONFIG_DIR / "ucode-mcp-config.json" LEGACY_MCP_BACKUP_PATH = APP_DIR / "copilot-ucode-mcp-config.backup.json" +MCP_CONFIG_ORIGINAL_PATH = APP_DIR / "copilot-mcp-config.original.json" COPILOT_PROXY_LOG_PATH = APP_DIR / "logs" / "copilot-proxy.log" _REGISTRY_PREFIX = "ug-providers-" _REGISTRY_LOCK_NAME = "ug-providers.lock" @@ -897,7 +898,14 @@ def _apply_mcp_diff(add: dict[str, dict], remove: set[str]) -> set[str]: "agent also skips the file while it has them." ) # Defer to the copilot CLI, which reads the file the way Copilot does, for exactly the - # changed entries. + # changed entries. It saves the file as plain JSON, dropping its comments, so the + # original is kept once for the developer to recover them. + first_cli_edit = (upserts or remove) and not MCP_CONFIG_ORIGINAL_PATH.exists() + if first_cli_edit and backup_existing_file(path, MCP_CONFIG_ORIGINAL_PATH): + print_note( + f"The Copilot CLI saves {path} without its comments; the original is at " + f"{MCP_CONFIG_ORIGINAL_PATH}." + ) removed = {name for name in remove if _remove_mcp_server_via_cli(name) or name in legacy} for name, entry in upserts.items(): _add_mcp_server_via_cli(name, entry) diff --git a/tests/conftest.py b/tests/conftest.py index 5bab80a48..558e3b624 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -137,6 +137,9 @@ def install_test_policy(path, desired_text, *, interactive=False, allow_sudo=Tru monkeypatch.setattr( copilot_mod, "LEGACY_MCP_BACKUP_PATH", state_dir / "copilot-ucode-mcp-config.backup.json" ) + monkeypatch.setattr( + copilot_mod, "MCP_CONFIG_ORIGINAL_PATH", state_dir / "copilot-mcp-config.original.json" + ) monkeypatch.delenv("COPILOT_HOME", raising=False) def reject_privileged_write(path, _desired_text): diff --git a/tests/test_agent_copilot.py b/tests/test_agent_copilot.py index 0315d088d..df94ceba2 100644 --- a/tests/test_agent_copilot.py +++ b/tests/test_agent_copilot.py @@ -928,7 +928,7 @@ def run(argv, **kwargs): monkeypatch.setattr(copilot.shutil, "which", lambda binary: f"/usr/bin/{binary}") return calls - def test_defers_to_the_copilot_cli_for_a_config_it_cannot_parse(self, monkeypatch): + def test_defers_to_the_copilot_cli_for_a_config_it_cannot_parse(self, monkeypatch, capsys): calls = self._stub_cli(monkeypatch) path = copilot.copilot_mcp_config_path() path.parent.mkdir(parents=True, exist_ok=True) @@ -946,9 +946,39 @@ def test_defers_to_the_copilot_cli_for_a_config_it_cannot_parse(self, monkeypatc ["copilot", "mcp", "remove", "svc"], ["copilot", "mcp", "add", "svc", "--", "ug", "mcp-proxy"], ] - assert path.read_text(encoding="utf-8") == jsonc + # The real CLI saves the file as plain JSON, so the original is kept for its comments. + assert copilot.MCP_CONFIG_ORIGINAL_PATH.read_text(encoding="utf-8") == jsonc + assert "without its comments" in " ".join(capsys.readouterr().out.split()) assert not copilot.LEGACY_MCP_CONFIG_PATH.exists() + def test_cli_edits_keep_only_the_first_original(self, monkeypatch, capsys): + self._stub_cli(monkeypatch) + path = copilot.copilot_mcp_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{\n // first\n}\n", encoding="utf-8") + copilot.write_user_mcp_servers({"svc": self.ENTRY}, set()) + capsys.readouterr() + path.write_text("{\n // second\n}\n", encoding="utf-8") + + copilot.write_user_mcp_servers({}, {"svc"}) + + assert copilot.MCP_CONFIG_ORIGINAL_PATH.read_text(encoding="utf-8") == "{\n // first\n}\n" + assert "without its comments" not in capsys.readouterr().out + + def test_a_cli_pass_with_nothing_to_change_keeps_no_original(self, monkeypatch): + calls = self._stub_cli(monkeypatch) + path = copilot.copilot_mcp_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{\n // mine\n}\n", encoding="utf-8") + copilot.LEGACY_MCP_BACKUP_PATH.parent.mkdir(parents=True, exist_ok=True) + copilot.LEGACY_MCP_BACKUP_PATH.write_text("{}", encoding="utf-8") + + copilot.migrate_legacy_mcp_config() + + assert calls == [] + assert not copilot.MCP_CONFIG_ORIGINAL_PATH.exists() + assert not copilot.LEGACY_MCP_BACKUP_PATH.exists() + def test_commented_config_without_the_copilot_cli_is_left_alone(self, monkeypatch): # VS Code alone has no Copilot CLI to edit a file ug can't parse. calls = self._stub_cli(monkeypatch) From 7ccabc3c8467e17da4e949e6a12ee39633fe751c Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Mon, 2 Nov 2026 13:29:36 +0000 Subject: [PATCH 12/18] copilot: drop the VS Code-only MCP path; VS Code reads the same user config The `copilot` agent will set up VS Code too, so the Copilot CLI is always installed where VS Code gets ug's MCP servers. That removes the need for a VS Code-only MCP client: the vscode_configured marker, `--agents vscode` on mcp/skills, the "GitHub Copilot in VS Code" label and the no-CLI `ug mcp list` fallback. A commented config without the Copilot CLI still fails with a clear error, and an emptied config is still deleted. Co-authored-by: Isaac --- src/ucode/agents/copilot.py | 6 ----- src/ucode/cli.py | 50 +++++++------------------------------ src/ucode/constants.py | 2 -- src/ucode/mcp.py | 45 ++++++--------------------------- tests/test_agent_copilot.py | 11 +------- tests/test_agent_vscode.py | 19 -------------- tests/test_cli.py | 43 ------------------------------- tests/test_mcp.py | 46 ---------------------------------- 8 files changed, 17 insertions(+), 205 deletions(-) diff --git a/src/ucode/agents/copilot.py b/src/ucode/agents/copilot.py index f2737bdca..faf48474b 100644 --- a/src/ucode/agents/copilot.py +++ b/src/ucode/agents/copilot.py @@ -823,12 +823,6 @@ def _read_mcp_config_for_rewrite(path: Path) -> dict | None: return data if isinstance(data, dict) else None -def user_mcp_server_names() -> set[str]: - """The servers in Copilot's user config, read without the Copilot CLI.""" - servers = (_read_mcp_config_for_rewrite(copilot_mcp_config_path()) or {}).get("mcpServers") - return set(servers) if isinstance(servers, dict) else set() - - def remove_legacy_mcp_config() -> bool: """Delete the legacy side file and its backup (everything in them is ug's).""" existed = LEGACY_MCP_CONFIG_PATH.exists() or LEGACY_MCP_BACKUP_PATH.exists() diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 31f883e40..853a6f789 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -59,8 +59,6 @@ AGENT_CLAUDE, AGENT_CODEX, AGENT_COPILOT, - AGENT_CURSOR, - AGENT_VSCODE, ISAAC_SMART_ROUTER_FEEDBACK_ENV_VAR, SMART_ROUTING_ENV_KEYS, UG_HEADERS_ENV_VAR, @@ -144,7 +142,6 @@ from ucode.mcp import ( CONFIG_WRITABLE_MCP_CLIENTS, MCP_CLIENTS, - VSCODE_CONFIGURED_KEY, McpServiceListingRateLimited, add_mcp_command, add_skills_command, @@ -155,8 +152,6 @@ configured_mcp_clients, configured_mcp_servers_by_name, list_mcp_command, - mcp_client_display, - mcp_clients_for_agents, purge_cross_workspace_mcp_residue, reconcile_managed_mcp_servers, remove_mcp_command, @@ -1722,9 +1717,9 @@ def revert() -> int: print_kv("Unity Gateway workspace policy", workspace_policy_result) print_kv("Pi settings", "restored" if pi_settings_restored else "unchanged") print_kv("VS Code extension", vscode_result) - for client in MCP_CLIENTS: + for client, spec in MCP_CLIENTS.items(): print_kv( - f"{mcp_client_display(client, state)} MCP config", + f"{spec['display']} MCP config", "restored" if mcp_results.get(client) else "unchanged", ) if desktop_result == "failed": @@ -2004,19 +1999,10 @@ def _configure_agents_for_mcp( workspace_entries: list[tuple[str, str | None]] | None = None, ) -> set[str]: """Select MCP clients for one workspace without changing agent model settings.""" - names = {a if a in (AGENT_CURSOR, AGENT_VSCODE) else normalize_tool(a) for a in requested} - scope = mcp_clients_for_agents(names) + scope = {a if a == "cursor" else normalize_tool(a) for a in requested} installed = available_mcp_clients() - # A named client whose user MCP config UG writes directly needs no CLI on PATH. VS Code reads - # Copilot's user config, so `vscode` alone needs a VS Code setup for the workspace rather than - # the Copilot CLI; that's checked once the workspace is selected. - vscode_only = AGENT_VSCODE in names and AGENT_COPILOT not in names - not_installed = sorted( - scope - - set(installed) - - CONFIG_WRITABLE_MCP_CLIENTS.keys() - - ({AGENT_COPILOT} if vscode_only else set()) - ) + # A named client whose user MCP config UG writes directly needs no CLI on PATH. + not_installed = sorted(scope - set(installed) - CONFIG_WRITABLE_MCP_CLIENTS.keys()) if not_installed: raise RuntimeError(_not_installed_message(not_installed)) previous_workspace = current_workspace_key(load_full_state()) @@ -2033,10 +2019,6 @@ def _configure_agents_for_mcp( else: profile = None state = load_state() - if vscode_only and AGENT_COPILOT not in installed and not state.get(VSCODE_CONFIGURED_KEY): - raise RuntimeError( - "VS Code isn't set up for this workspace yet. Run `ug configure --agents vscode` first." - ) host, workspace_id = split_workspace_key(workspace) state["workspace"] = host if workspace_id: @@ -2204,8 +2186,6 @@ def mcp_add( recorded = set((bucket or {}).get("available_tools") or []) | set( (bucket or {}).get("mcp_clients") or [] ) - if (bucket or {}).get(VSCODE_CONFIGURED_KEY): - recorded.add(AGENT_COPILOT) existing_scope = ( set(configured_mcp_clients(bucket or {}, available_mcp_clients())) & recorded ) @@ -2255,10 +2235,7 @@ def mcp_remove( requested_agents = ( None if agents is None - else ( - mcp_clients_for_agents({a.strip().lower() for a in agents.split(",") if a.strip()}) - or None - ) + else ({a.strip().lower() for a in agents.split(",") if a.strip()} or None) ) try: remove_mcp_command(agents=requested_agents) @@ -2291,10 +2268,7 @@ def mcp_list( requested_agents = ( None if agents is None - else ( - mcp_clients_for_agents({a.strip().lower() for a in agents.split(",") if a.strip()}) - or None - ) + else ({a.strip().lower() for a in agents.split(",") if a.strip()} or None) ) try: list_mcp_command(agents=requested_agents) @@ -2555,12 +2529,7 @@ def skills_remove( requested_agents = ( None if agents is None - else ( - mcp_clients_for_agents( - {a.strip().lower() for a in agents.split(",") if a.strip()} - ) - or None - ) + else ({a.strip().lower() for a in agents.split(",") if a.strip()} or None) ) locations = _parse_skill_locations(location) if locations: @@ -4897,8 +4866,7 @@ def configure( state = load_state() managed, _ = _read_policy_for_other_agent(state) refuse_user_scope_over_managed_config(state.get("workspace"), managed) - if vscode.configure(state, provider=provider, location=model_location): - save_state({**state, VSCODE_CONFIGURED_KEY: True}) + vscode.configure(state, provider=provider, location=model_location) if mcp_states is not None: _restore_mcp_after_workspace_switch(previous_workspace, mcp_states[0]) elif mcp is not None: diff --git a/src/ucode/constants.py b/src/ucode/constants.py index 93a42eb35..db9b2ea0f 100644 --- a/src/ucode/constants.py +++ b/src/ucode/constants.py @@ -8,8 +8,6 @@ AGENT_CLAUDE = "claude" AGENT_CODEX = "codex" AGENT_COPILOT = "copilot" -AGENT_CURSOR = "cursor" -AGENT_VSCODE = "vscode" ENABLE_SMART_ROUTING_ENV_VAR = "ENABLE_SMART_ROUTING_V2" ENABLE_SUBAGENT_ROUTING_ENV_VAR = "ENABLE_SMART_ROUTING_SUBAGENT_ONLY" diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index 022cbc8f0..28f415485 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -138,9 +138,6 @@ # MCP-only clients ucode never launches for model routing, so they never land in # `available_tools`; they're eligible for MCP config purely on being installed. MCP_ONLY_CLIENTS = ("cursor",) -# Copilot Chat in VS Code reads Copilot's user MCP config, so a workspace whose state carries this -# key gets the `copilot` client even without the Copilot CLI, and `--agents vscode` names it. -VSCODE_CONFIGURED_KEY = "vscode_configured" EXTERNAL_MCP_SELECTION_PREFIX = "external:" SQL_MCP_VALUE = "managed:sql" GENIE_SPACE_SELECTION_PREFIX = "genie-space:" @@ -283,36 +280,11 @@ def _config_only_mcp_client(client: str) -> bool: return path is not None and path().is_file() -def _with_vscode_copilot(state: dict, clients: list[str]) -> list[str]: - """``clients`` plus ``copilot`` when VS Code is set up for this workspace.""" - if not state.get(VSCODE_CONFIGURED_KEY): - return clients - return [client for client in MCP_CLIENTS if client in clients or client == "copilot"] - - -def mcp_clients_for_agents(agents: set[str]) -> set[str]: - """``--agents`` names as MCP clients.""" - return {"copilot" if agent == "vscode" else agent for agent in agents} - - -def mcp_client_display(client: str, state: dict) -> str: - if ( - client == "copilot" - and state.get(VSCODE_CONFIGURED_KEY) - and not shutil.which(str(MCP_CLIENTS[client]["binary"])) - ): - return "GitHub Copilot in VS Code" - return str(MCP_CLIENTS[client]["display"]) - - def configured_mcp_clients(state: dict, installed_clients: list[str]) -> list[str]: configured_tools = state.get("available_tools") or [] if not isinstance(configured_tools, list): configured_tools = [] configured = set(configured_tools) | set(state.get("mcp_clients") or []) - if state.get(VSCODE_CONFIGURED_KEY): - configured.add("copilot") - installed_clients = _with_vscode_copilot(state, installed_clients) return [ client for client in MCP_CLIENTS @@ -542,7 +514,7 @@ def revert_mcp_configs(state: dict) -> dict[str, bool]: try: removed_scopes = remove_client_mcp_server(client, name) except RuntimeError as exc: - display = mcp_client_display(client, state) + display = MCP_CLIENTS[client]["display"] print_warning(f"Could not remove MCP server `{name}` from {display}: {exc}") continue results[client] = bool(removed_scopes) or results.get(client, False) @@ -1702,7 +1674,7 @@ def restore_workspace_mcp_servers(state: dict, agents: set[str]) -> None: workspace = state.get("workspace") if not workspace: return - installed = set(_with_vscode_copilot(state, available_mcp_clients())) & agents + installed = set(available_mcp_clients()) & agents managed_names = managed_mcp_server_names(state, installed) servers = list(state.get("mcp_servers") or []) for client in sorted(installed): @@ -1801,7 +1773,7 @@ def remove_for(client: str, names: set[str]) -> None: def purge_cross_workspace_mcp_residue( state: dict, workspace: str, *, same_host_switch: bool = False ) -> None: - installed = set(_with_vscode_copilot(state, available_mcp_clients())) + installed = set(available_mcp_clients()) # Collect every removal as {client: {names}} and apply it as one batched write per client below, # rather than a CLI/config removal per server (see `_batch_remove_stale_servers`). batch_remove: dict[str, set[str]] = {} @@ -2014,7 +1986,7 @@ def setup_mcp_clients( if purge_foreign: purge_cross_workspace_mcp_residue(state, workspace) - installed_clients = _with_vscode_copilot(state, available_mcp_clients()) + installed_clients = available_mcp_clients() explicit_direct = ( set(state.get("mcp_clients") or []) & (agents or set()) & CONFIG_WRITABLE_MCP_CLIENTS.keys() ) @@ -2052,7 +2024,7 @@ def setup_mcp_clients( if not quiet: print_section(section) - client_names = ", ".join(mcp_client_display(client, state) for client in clients) + client_names = ", ".join(str(MCP_CLIENTS[client]["display"]) for client in clients) print_note(f"{action_note}: {client_names}") for client in missing_clients: print_warning( @@ -2710,9 +2682,6 @@ def _run_mcp_list(client: str) -> str | None: def query_live_mcp_status(client: str) -> dict[str, str]: """MCP ``{server_name: state}`` for one client, including config-only status.""" - if client == "copilot" and not shutil.which(str(MCP_CLIENTS[client]["binary"])): - # VS Code only: no Copilot CLI to ask, so report what Copilot's user config holds. - return dict.fromkeys(copilot.user_mcp_server_names(), LIVE_ENABLED) config_only = _config_only_mcp_client(client) and not _mcp_cli_available(client) output = None if config_only else _run_mcp_list(client) statuses = parse_mcp_list_output(client, output) if output is not None else {} @@ -2909,7 +2878,7 @@ def list_mcp_command(agents: set[str] | None = None) -> int: ) state = load_state() - installed = _with_vscode_copilot(state, available_mcp_clients()) + installed = available_mcp_clients() probe_clients = [client for client in installed if agents is None or client in agents] scope_note = "" if agents is None else f" for {', '.join(sorted(agents))}" @@ -2973,7 +2942,7 @@ def list_mcp_command(agents: set[str] | None = None) -> int: ) # Only mention the enabled/disabled caveat for the agents actually probed through their CLI. config_state_clients = [ - mcp_client_display(client, state) + MCP_CLIENTS[client]["display"] for client in ("codex", "copilot") if client in probe_clients and client not in config_only ] diff --git a/tests/test_agent_copilot.py b/tests/test_agent_copilot.py index df94ceba2..a038be0a9 100644 --- a/tests/test_agent_copilot.py +++ b/tests/test_agent_copilot.py @@ -980,7 +980,7 @@ def test_a_cli_pass_with_nothing_to_change_keeps_no_original(self, monkeypatch): assert not copilot.LEGACY_MCP_BACKUP_PATH.exists() def test_commented_config_without_the_copilot_cli_is_left_alone(self, monkeypatch): - # VS Code alone has no Copilot CLI to edit a file ug can't parse. + # Without the Copilot CLI, ug has nothing to edit a file it can't parse with. calls = self._stub_cli(monkeypatch) monkeypatch.setattr(copilot.shutil, "which", lambda binary: None) path = copilot.copilot_mcp_config_path() @@ -994,15 +994,6 @@ def test_commented_config_without_the_copilot_cli_is_left_alone(self, monkeypatc assert calls == [] assert path.read_text(encoding="utf-8") == jsonc - def test_user_mcp_server_names_reads_the_config_without_the_cli(self): - path = copilot.copilot_mcp_config_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps({"mcpServers": {"svc": self.ENTRY}}), encoding="utf-8") - assert copilot.user_mcp_server_names() == {"svc"} - - path.write_text('{\n // mine\n "mcpServers": {"svc": {}}\n}\n', encoding="utf-8") - assert copilot.user_mcp_server_names() == set() - def test_removing_the_last_server_deletes_a_config_with_nothing_else(self): path = copilot.copilot_mcp_config_path() path.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_agent_vscode.py b/tests/test_agent_vscode.py index 18449e975..a5dd7b5da 100644 --- a/tests/test_agent_vscode.py +++ b/tests/test_agent_vscode.py @@ -16,7 +16,6 @@ from ucode.cli import app from ucode.databricks import AnthropicModelCatalog from ucode.managed_files import OS -from ucode.mcp import VSCODE_CONFIGURED_KEY # The conftest fixture stubs these out for every test; the ones here exercise the real lookups. REAL_FIND_CODE = vscode.find_code @@ -512,24 +511,6 @@ def test_configure_agents_vscode_sets_up_the_workspace_then_vscode(monkeypatch, assert ("VS Code isn't changed" in result.output) == dry_run -@pytest.mark.parametrize("installed", [True, False]) -def test_configure_agents_vscode_records_vscode_for_mcp_only_when_it_installs( - monkeypatch, installed -): - saved: list[dict] = [] - monkeypatch.setattr(config_io, "_dry_run", False) - monkeypatch.setattr("ucode.cli._configure_shared_workspace_states", lambda *a, **k: [STATE]) - monkeypatch.setattr("ucode.cli.load_state", lambda: STATE) - monkeypatch.setattr("ucode.cli.save_state", saved.append) - monkeypatch.setattr(vscode, "configure", lambda state, **_: installed) - - result = CliRunner().invoke(app, ["configure", "--agents", "vscode", "--workspace", WS]) - - assert result.exit_code == 0, result.output - assert saved == ([{**STATE, VSCODE_CONFIGURED_KEY: True}] if installed else []) - assert VSCODE_CONFIGURED_KEY not in STATE - - @pytest.mark.parametrize( ("args", "error"), [ diff --git a/tests/test_cli.py b/tests/test_cli.py index 35d2e8cbe..d8e30d5c1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2505,17 +2505,6 @@ def test_mcp_list_forwards_agents_option(self, monkeypatch): assert result.exit_code == 0, result.output assert calls == [{"claude", "codex"}] - @pytest.mark.parametrize("command", ["list", "remove"]) - def test_mcp_agents_vscode_means_copilot_config(self, monkeypatch, command): - calls: list[set[str] | None] = [] - monkeypatch.setattr( - cli_mod, f"{command}_mcp_command", lambda agents=None: calls.append(agents) - ) - monkeypatch.setattr(cli_mod, "_sync_claude_cowork_plugin", lambda: None) - result = runner.invoke(app, ["mcp", command, "--agents", "vscode,claude"]) - assert result.exit_code == 0, result.output - assert calls == [{"copilot", "claude"}] - def test_mcp_add_syncs_claude_cowork(self): with ( patch("ucode.cli.add_mcp_command") as mock_add, @@ -3356,38 +3345,6 @@ def test_other_clients_without_cli_still_fail_before_prompt(self): prompt.assert_not_called() - def test_vscode_targets_copilot_config_without_the_copilot_cli(self): - state = {"workspace": "https://ws", cli_mod.VSCODE_CONFIGURED_KEY: True} - with ( - patch("ucode.cli.load_state", return_value=state), - patch("ucode.cli.available_mcp_clients", return_value=["claude"]), - patch("ucode.cli.configure_workspace_command") as mock_cfg, - ): - scope = cli_mod._configure_agents_for_mcp(["vscode"]) - - assert scope == {"copilot"} - mock_cfg.assert_not_called() - - def test_vscode_not_set_up_errors_instead_of_installing_copilot(self): - with ( - patch("ucode.cli.load_state", return_value={"workspace": "https://ws"}), - patch("ucode.cli.available_mcp_clients", return_value=[]), - patch("ucode.cli.configure_workspace_command") as mock_cfg, - pytest.raises(RuntimeError, match="ug configure --agents vscode"), - ): - cli_mod._configure_agents_for_mcp(["vscode"]) - - mock_cfg.assert_not_called() - - def test_vscode_with_copilot_still_needs_the_copilot_cli(self): - state = {"workspace": "https://ws", cli_mod.VSCODE_CONFIGURED_KEY: True} - with ( - patch("ucode.cli.load_state", return_value=state), - patch("ucode.cli.available_mcp_clients", return_value=[]), - pytest.raises(RuntimeError, match="copilot"), - ): - cli_mod._configure_agents_for_mcp(["vscode", "copilot"]) - class TestMcpAddWorkspace: def test_workspace_flag_forwarded_to_agent_setup(self): diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 7291925c9..6c05973bc 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -285,52 +285,6 @@ def test_skipped_when_binary_not_installed(self): assert "cursor" not in clients -class TestVscodeMcpClient: - STATE = {"workspace": WS, "available_tools": [], mcp.VSCODE_CONFIGURED_KEY: True} - - def test_vscode_setup_makes_copilot_eligible_without_its_cli(self): - assert mcp.configured_mcp_clients(self.STATE, ["claude"]) == ["copilot"] - - def test_copilot_still_needs_its_cli_without_a_vscode_setup(self): - assert mcp.configured_mcp_clients({"available_tools": ["copilot"]}, ["claude"]) == [] - - def test_agents_vscode_names_the_copilot_client(self): - assert mcp.mcp_clients_for_agents({"vscode", "claude"}) == {"copilot", "claude"} - - def test_setup_targets_copilot_in_vscode_when_no_agent_cli_is_installed( - self, monkeypatch, capsys - ): - monkeypatch.setattr(mcp.shutil, "which", lambda binary: None) - - _, _, clients = mcp.setup_mcp_clients(self.STATE, "MCP", require_auth=False) - - assert clients == ["copilot"] - assert "GitHub Copilot in VS Code" in _unwrap(capsys.readouterr().out) - - def test_list_reads_copilot_config_when_its_cli_is_missing(self, monkeypatch): - monkeypatch.setattr(mcp.shutil, "which", lambda binary: None) - path = mcp.copilot.copilot_mcp_config_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps({"mcpServers": {"svc": {"type": "local"}}}), encoding="utf-8") - - assert mcp.query_live_mcp_status("copilot") == {"svc": mcp.LIVE_ENABLED} - - @pytest.mark.parametrize( - ("vscode", "cli", "label"), - [ - (True, None, "GitHub Copilot in VS Code"), - (True, "/usr/bin/copilot", "GitHub Copilot CLI"), - (False, None, "GitHub Copilot CLI"), - ], - ) - def test_copilot_is_labeled_vscode_only_for_a_vscode_setup_without_the_cli( - self, monkeypatch, vscode, cli, label - ): - monkeypatch.setattr(mcp.shutil, "which", lambda binary: cli) - state = {mcp.VSCODE_CONFIGURED_KEY: True} if vscode else {} - assert mcp.mcp_client_display("copilot", state) == label - - class TestKiloMcpClient: def test_kilo_registered_in_mcp_clients(self): assert "kilo" in mcp.MCP_CLIENTS From 749303f6f012aa1a6c0bf5aedbfc63bd37125b4d Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Mon, 2 Nov 2026 13:40:32 +0000 Subject: [PATCH 13/18] copilot: set up Copilot Chat in VS Code with the copilot agent; drop the vscode agent `ug configure` now sets up VS Code whenever it configures the copilot agent, from the same model source as the Copilot CLI: the picker, `--agents copilot`, `--agent copilot` and a managed workspace all get both. `--no-vscode` sets up only the CLI. Without VS Code's `code` command, ug notes that it skipped VS Code; a failed extension install warns and the run goes on. A launch that configures copilot on the fly leaves VS Code alone. `--provider` / `--model-location` now go with `--agents copilot` and are saved as Copilot's model source, so `ug copilot` launches on the same models VS Code gets. They're refused on a workspace with a managed configuration, whose admin chooses the models. `vscode` is no longer an agent name. The picker, `ug status` and `ug agents list` call the agent "GitHub Copilot (CLI and VS Code)"; the extension's messages point at `ug configure --agents copilot`, and its bundle is rebuilt. Co-authored-by: Isaac --- src/ucode/agents/__init__.py | 39 +++- src/ucode/agents/vscode.py | 11 +- src/ucode/cli.py | 83 ++++++--- src/ucode/vscode/extension/out/extension.js | 6 +- tests/conftest.py | 2 + tests/test_agent_vscode.py | 192 ++++++++++++++------ tests/test_cli.py | 14 +- vscode-extension/README.md | 4 +- vscode-extension/src/extension.ts | 2 +- vscode-extension/src/gateway.ts | 2 +- vscode-extension/test/extension.test.ts | 4 +- 11 files changed, 253 insertions(+), 106 deletions(-) diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index 46b666bc0..d1b649d58 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -17,7 +17,7 @@ from collections.abc import Callable from pathlib import Path -from ucode.config_io import ToolSpec +from ucode.config_io import ToolSpec, is_dry_run from ucode.constants import AGENT_CLAUDE, AGENT_CODEX, AGENT_COPILOT from ucode.databricks import ( AnthropicModelCatalog, @@ -686,10 +686,38 @@ def _availability_failure_detail(tool: str, state: dict) -> str: return " (" + "; ".join(parts) + ")" +_vscode_with_copilot = True + + +def request_vscode_with_copilot(requested: bool = True) -> None: + """Whether configuring Copilot in this run also sets up VS Code (``ug configure --no-vscode`` + passes ``False``).""" + global _vscode_with_copilot + _vscode_with_copilot = requested + + +def configure_vscode(state: dict, *, provider: str | None, parent_schema: str | None) -> None: + """Set up Copilot Chat in VS Code with the models the Copilot CLI was configured with.""" + if not _vscode_with_copilot: + return + if is_dry_run(): + print_note("Dry run: VS Code isn't changed.") + return + from . import vscode + + vscode.configure(state, provider=provider, location=parent_schema) + + def configure_single_tool( - tool: str, state: dict, *, parent_schema: str | None = None, provider: str | None = None + tool: str, + state: dict, + *, + parent_schema: str | None = None, + provider: str | None = None, + with_vscode: bool = False, ) -> dict: - """Check availability, configure, and persist state for one tool only.""" + """Check availability, configure, and persist state for one tool only. ``with_vscode`` also + sets up VS Code after Copilot.""" parent_schema = parent_schema or _persisted_model_location(tool, state, provider) provider = None if parent_schema else provider or get_provider_service(state, tool) # A Model Provider Service or parent schema routes through the same gateway and pins no @@ -704,6 +732,8 @@ def configure_single_tool( ) with managed_write_batch(_managed_settings_displays([tool])): state = _configure_one(tool, state, provider, parent_schema=parent_schema) + if with_vscode and tool == AGENT_COPILOT: + configure_vscode(state, provider=provider, parent_schema=parent_schema) available_tools = list(set((state.get("available_tools") or []) + [tool])) state["available_tools"] = available_tools save_state(state) @@ -788,6 +818,9 @@ def configure_selected_tools( ) continue configured.append(tool) + # `ug configure` sets up Copilot Chat in VS Code along with the Copilot CLI. + if tool == AGENT_COPILOT: + configure_vscode(state, provider=provider, parent_schema=parent_schema) existing = state.get("available_tools") or [] state["available_tools"] = sorted(set(existing) | set(configured)) diff --git a/src/ucode/agents/vscode.py b/src/ucode/agents/vscode.py index 72c354e00..efdf7a517 100644 --- a/src/ucode/agents/vscode.py +++ b/src/ucode/agents/vscode.py @@ -37,7 +37,7 @@ from ucode.os_compatibility import subprocess_cross_os from ucode.os_compatibility.system_cross_os import windows_program_files_dir from ucode.string_utils import model_picker_label -from ucode.ui import console, print_heading, print_kv, print_warning +from ucode.ui import console, print_heading, print_kv, print_note, print_warning EXTENSION_DIR = Path(__file__).resolve().parent.parent / "vscode" / "extension" EXTENSION_ID = "databricks.unity-gateway" @@ -251,9 +251,10 @@ def configure(state: dict, *, provider: str | None = None, location: str | None after a warning, if any step fails.""" code = find_code() if code is None: - print_warning( - "VS Code's `code` command wasn't found. Install VS Code (on macOS, also run " - "\"Shell Command: Install 'code' command in PATH\"), then rerun `ug configure --agents vscode`." + print_note( + "VS Code's `code` command wasn't found, so VS Code was skipped. To add it, install VS " + "Code (on macOS, also run \"Shell Command: Install 'code' command in PATH\"), then " + "rerun `ug configure --agents copilot`." ) return False try: @@ -276,7 +277,7 @@ def configure(state: dict, *, provider: str | None = None, location: str | None f"{install_extension(code, config)} with {len(models)} models{source}", ) except RuntimeError as exc: - print_warning(f"{exc} Rerun `ug configure --agents vscode` to try again.") + print_warning(f"{exc} Rerun `ug configure --agents copilot` to try again.") return False print_heading("Next, in VS Code:") for line in next_steps(): diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 853a6f789..ca6784f37 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -40,6 +40,7 @@ normalize_tool, refuse_user_scope_over_managed_config, refuse_user_scope_over_managed_files, + request_vscode_with_copilot, resolve_gemini_provider_model, resolve_launch_model, resolve_provider_models, @@ -1011,7 +1012,7 @@ def _setup_single_agent( state = resolve_state(managed, state, tool) if not managed_provider_service(managed, tool): parent_schema = managed_unity_catalog_location(managed, tool) - state = configure_single_tool(tool, state, parent_schema=parent_schema) + state = configure_single_tool(tool, state, parent_schema=parent_schema, with_vscode=True) if apply_managed and not managed_enabled_tools(managed or {}): state = _record_unmanaged_configured_agents(state, [tool]) if apply_managed and tool == "claude": @@ -1189,12 +1190,16 @@ def configure_workspace_command( databricks_ai_tools_enabled: bool | None = None, custom_oauth: CustomOAuthConfig | None = None, offer_optional_setup: bool = False, + copilot_source: tuple[str | None, str | None] | None = None, ) -> int: """Configure a workspace while sharing one lazy privileged settings session. Agent setup and managed MCP reconciliation can update the same machine-wide Claude/Codex files at different points in the flow. Keeping one command-scoped worker means every changed file is handled under the same sudo authentication; a no-op configure never starts it. + + ``copilot_source`` is the ``(provider service, model location)`` pair Copilot gets its models + from instead of the Databricks-hosted ones. """ with managed_write_session(): return _configure_workspace_command( @@ -1205,6 +1210,7 @@ def configure_workspace_command( databricks_ai_tools_enabled=databricks_ai_tools_enabled, custom_oauth=custom_oauth, offer_optional_setup=offer_optional_setup, + copilot_source=copilot_source, ) @@ -1217,6 +1223,7 @@ def _configure_workspace_command( databricks_ai_tools_enabled: bool | None = None, custom_oauth: CustomOAuthConfig | None = None, offer_optional_setup: bool = False, + copilot_source: tuple[str | None, str | None] | None = None, ) -> int: if tool is not None and selected_tools is not None: raise RuntimeError("Use either --agent or --agents, not both.") @@ -1260,6 +1267,11 @@ def _configure_workspace_command( refuse_user_scope_over_managed_config(state.get("workspace"), managed) managed_tools = managed_enabled_tools(managed) if managed is not None else [] if managed is not None and managed_tools: + if copilot_source is not None: + raise RuntimeError( + "--provider and --model-location can't be used on a workspace with a managed " + "configuration; its admin chooses the models." + ) refuse_user_scope_over_managed_files(managed_tools) configured_tools: list[str] = [] for tool_name in managed_tools: @@ -1329,7 +1341,7 @@ def _configure_workspace_command( raise RuntimeError("No coding agents are available on this workspace.") if selected_tools is None: - picked = prompt_for_tools([(t, TOOL_SPECS[t]["display"]) for t in available_on_workspace]) + picked = prompt_for_tools([(t, _agent_label(t)) for t in available_on_workspace]) else: unavailable_tools = [ tool_name for tool_name in selected_tools if tool_name not in available_on_workspace @@ -1358,6 +1370,11 @@ def _configure_workspace_command( if offer_provider: for tool_name in picked: state = _maybe_select_provider_service(tool_name, state) + if copilot_source is not None: + # Saved, so `ug copilot` launches on the same models VS Code gets. + provider_service, model_location = copilot_source + state = set_provider_service(state, AGENT_COPILOT, provider_service) + state = set_model_location(state, AGENT_COPILOT, model_location) if offer_optional_setup: state = configure_selected_tools(state, picked, install_ai_tools=False) @@ -1414,6 +1431,14 @@ def _configure_workspace_command( return 0 +def _agent_label(tool: str) -> str: + """An agent's name where developers choose and review agents: the picker, status, list.""" + if tool == AGENT_COPILOT: + # Configuring copilot also sets up Copilot Chat in VS Code. + return "GitHub Copilot (CLI and VS Code)" + return str(TOOL_SPECS[tool]["display"]) + + def _print_status_panel(title: str, rows: list[tuple[str, str]]) -> None: """Render a compact two-column status card with values that wrap safely.""" table = Table.grid(padding=(0, 2)) @@ -1604,7 +1629,7 @@ def status() -> int: model_state, model_freshness = _live_status_model_state(state, configured_tools) print_heading("Coding Agents") skill_counts_by_agent = configured_skill_counts_by_agent(state, TOOL_SPECS) - for tool, spec in TOOL_SPECS.items(): + for tool in TOOL_SPECS: if tool not in configured_tools: continue effective_state = resolve_state(managed, model_state, tool) if managed else model_state @@ -1659,7 +1684,7 @@ def status() -> int: if isinstance(base_url, dict): base_url = ", ".join(str(url) for url in base_url.values()) rows.append(("Endpoint", str(base_url or "not configured"))) - _print_status_panel(str(spec["display"]), rows) + _print_status_panel(_agent_label(tool), rows) if not configured_tools: print_note("No coding agents are configured.") @@ -1967,7 +1992,7 @@ def agents_list_cmd() -> None: if management is AgentManagement.UNMANAGED: continue tone = "ok" if management is AgentManagement.ADMIN else "info" - table.add_row(TOOL_SPECS[tool]["display"], status_badge(management.value, tone)) + table.add_row(_agent_label(tool), status_badge(management.value, tone)) if table.row_count: console.print(table) else: @@ -4574,7 +4599,7 @@ def configure( str | None, typer.Option( "--agents", - help="Configure a comma-separated list of agents without prompting (e.g. claude,codex,vscode).", + help="Configure a comma-separated list of agents without prompting (e.g. claude,codex,copilot).", ), ] = None, workspace: Annotated[ @@ -4675,18 +4700,26 @@ def configure( str | None, typer.Option( "--provider", - help="With --agents vscode: give VS Code the models of this Model Provider Service " - "(`..`) instead of the Databricks-hosted ones.", + help="With --agents copilot: give the Copilot CLI and VS Code the models of this Model " + "Provider Service (`..`) instead of the Databricks-hosted ones.", ), ] = None, model_location: Annotated[ str | None, typer.Option( "--model-location", - help="With --agents vscode: give VS Code the model services in this Unity Catalog " - "schema (`.`) instead of the Databricks-hosted ones.", + help="With --agents copilot: give the Copilot CLI and VS Code the model services in this " + "Unity Catalog schema (`.`) instead of the Databricks-hosted ones.", ), ] = None, + no_vscode: Annotated[ + bool, + typer.Option( + "--no-vscode", + help="Set up only the Copilot CLI. By default, configuring copilot also sets up " + "Copilot Chat in VS Code.", + ), + ] = False, mcp: Annotated[ str | None, typer.Option( @@ -4748,6 +4781,7 @@ def configure( claude_agent.request_explicit_configure() request_explicit_managed_writes() codex_agent.request_explicit_configure() + request_vscode_with_copilot(not no_vscode) custom_oauth = _custom_oauth_config(client_id, redirect_url, scopes) if custom_oauth is not None and use_pat: raise RuntimeError("--client-id cannot be combined with --use-pat.") @@ -4756,13 +4790,13 @@ def configure( raise RuntimeError("Use either --agent or --agents, not both.") if provider is not None or model_location is not None: requested_agents = [a.strip().lower() for a in (agents or "").split(",") if a.strip()] - # Both flags choose VS Code's models alone, so another model agent in the same run + # Both flags choose Copilot's models alone, so another model agent in the same run # would silently keep the workspace's own. Cursor has no models to choose. - if "vscode" not in requested_agents or [ - a for a in requested_agents if a not in ("vscode", "cursor") + if AGENT_COPILOT not in requested_agents or [ + a for a in requested_agents if a not in (AGENT_COPILOT, "cursor") ]: raise RuntimeError( - "--provider and --model-location work only with `--agents vscode`. " + "--provider and --model-location work only with `--agents copilot`. " "Configure the other agents in a separate run." ) if provider is not None and model_location is not None: @@ -4798,6 +4832,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 provider is not None or model_location is not None: + skip_kwargs["copilot_source"] = (provider, 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 @@ -4825,11 +4861,9 @@ def configure( # (which picks cursor up through MCP_ONLY_CLIENTS). If cursor is the # only agent, do a workspace-only configure so that a later `ug mcp # add` run has a current workspace to target. - # VS Code is configure-only too: it needs the workspace, then its own setup below. requested = [a.strip().lower() for a in agents.split(",") if a.strip()] wants_cursor = "cursor" in requested - wants_vscode = "vscode" in requested - model_agent_names = ",".join(a for a in requested if a not in ("cursor", "vscode")) + model_agent_names = ",".join(a for a in requested if a != "cursor") mcp_states = None if model_agent_names: selected_tools = _parse_agents_option(model_agent_names) @@ -4844,8 +4878,8 @@ def configure( workspaces=workspace_entries, **skip_kwargs, ) - elif wants_cursor or wants_vscode: - # Cursor/VS Code only: establish workspace state without the model picker. + elif wants_cursor: + # Cursor only: establish workspace state without the model picker. mcp_states = _configure_shared_workspace_states( workspace_entries or [_prompt_for_configuration(None)], tools=[], @@ -4856,17 +4890,6 @@ def configure( else: # Neither model agents nor cursor -> empty/invalid --agents list. _parse_agents_option(agents) - if wants_vscode and is_dry_run(): - # A dry run saves no workspace, so the state on disk isn't the one being configured. - print_note("Dry run: VS Code isn't changed.") - elif wants_vscode: - from ucode.agents import vscode - - # VS Code sends model traffic, so it gets the managed-workspace check other agents get. - state = load_state() - managed, _ = _read_policy_for_other_agent(state) - refuse_user_scope_over_managed_config(state.get("workspace"), managed) - vscode.configure(state, provider=provider, location=model_location) if mcp_states is not None: _restore_mcp_after_workspace_switch(previous_workspace, mcp_states[0]) elif mcp is not None: diff --git a/src/ucode/vscode/extension/out/extension.js b/src/ucode/vscode/extension/out/extension.js index cf995e308..b74715d6e 100644 --- a/src/ucode/vscode/extension/out/extension.js +++ b/src/ucode/vscode/extension/out/extension.js @@ -1,4 +1,4 @@ -"use strict";var se=Object.create;var b=Object.defineProperty;var ae=Object.getOwnPropertyDescriptor;var re=Object.getOwnPropertyNames;var ie=Object.getPrototypeOf,ce=Object.prototype.hasOwnProperty;var le=(e,n)=>{for(var o in n)b(e,o,{get:n[o],enumerable:!0})},j=(e,n,o,s)=>{if(n&&typeof n=="object"||typeof n=="function")for(let t of re(n))!ce.call(e,t)&&t!==o&&b(e,t,{get:()=>n[t],enumerable:!(s=ae(n,t))||s.enumerable});return e};var L=(e,n,o)=>(o=e!=null?se(ie(e)):{},j(n||!e||!e.__esModule?b(o,"default",{value:e,enumerable:!0}):o,e)),de=e=>j(b({},"__esModule",{value:!0}),e);var Te={};le(Te,{CONFIG_FILE:()=>Q,DatabricksModels:()=>k,PROVIDER_HEADER:()=>X,activate:()=>Ce,readConfig:()=>Z,userAgent:()=>ee});module.exports=de(Te);var z=require("node:fs"),V=require("node:path"),h=L(require("vscode"));var M="0.1.0";var I=require("node:child_process"),pe=5*6e4,ue=60*6e4,me=e=>new Promise((n,o)=>{(0,I.exec)(e,{timeout:6e4,windowsHide:!0},(s,t,r)=>{s?o(new Error(`Couldn't get a Databricks token: \`${e}\` failed: ${String(r).trim().slice(0,500)||s.message}`)):n(String(t).trim())})}),fe=e=>{try{let{exp:n}=JSON.parse(Buffer.from(e.split(".")[1]??"","base64url").toString());if(typeof n=="number")return n*1e3}catch{}return Date.now()+ue},x=class{constructor(n,o=()=>{}){this.command=n;this.onFetch=o}command;onFetch;cached;pending;get(){return this.cached&&this.cached.expiresAt-pe>Date.now()?Promise.resolve(this.cached.value):this.fetch(this.command)}async renew(n){return await this.pending?.catch(()=>{}),this.cached&&this.cached.value!==n?this.cached.value:(this.cached=void 0,this.fetch(`${this.command} --force-refresh`))}fetch(n){return this.pending??=me(n).then(o=>{if(!o)throw new Error(`Couldn't get a Databricks token: \`${n}\` printed nothing.`);return this.cached={value:o,expiresAt:fe(o)},this.onFetch(this.cached.expiresAt),o}).finally(()=>{this.pending=void 0}),this.pending}};var l=L(require("vscode")),C=3,he=1e3,w=e=>e instanceof l.LanguageModelTextPart?e.value:e instanceof l.LanguageModelDataPart&&!e.mimeType.startsWith("image/")?new TextDecoder().decode(e.data):e instanceof l.LanguageModelPromptTsxPart?JSON.stringify(e.value):"",D=e=>e instanceof l.LanguageModelDataPart&&e.mimeType.startsWith("image/")?{mimeType:e.mimeType,data:Buffer.from(e.data).toString("base64")}:void 0,$=e=>{let n=D(e);return n&&`data:${n.mimeType};base64,${n.data}`},T=e=>e.inputSchema??{type:"object",properties:{}},R=(e,n,o)=>{let s;try{s=o.trim()?JSON.parse(o):{}}catch{s=void 0}if(typeof s!="object"||s===null||Array.isArray(s))throw new Error(`The model called ${n} with arguments that aren't a JSON object: ${o}`);return new l.LanguageModelToolCallPart(e,n,s)},S=e=>e==="max_output_tokens"||e==="length"||e==="max_tokens"?new Error("The model stopped at its output token limit before finishing the response."):e==="content_filter"||e==="refusal"?new Error("The model's response was stopped by a content filter."):new Error(`The model stopped before finishing the response (${e??"no reason given"}).`),P=()=>new Error("The response ended before the model finished. Try again."),q=(e,n)=>e.tools.length?{tools:e.tools.map(n),...e.toolRequired?{tool_choice:"required"}:{}}:{},G=e=>{if(typeof e=="string")return Math.ceil(e.length/4);let n=0,o=0;for(let s of e.content){let t=s instanceof l.LanguageModelToolResultPart?s.content:[s];for(let r of t)n+=r instanceof l.LanguageModelToolCallPart?r.name.length+JSON.stringify(r.input).length:w(r).length,o+=$(r)===void 0?0:1}return Math.ceil(n/4)+o*he},A=e=>e.map($).filter(n=>n!==void 0),E=e=>({results:e.filter(n=>n instanceof l.LanguageModelToolResultPart),calls:e.filter(n=>n instanceof l.LanguageModelToolCallPart),text:e.map(w).join(""),images:A(e)});function J(e){let n=e.messages.flatMap(o=>{let{results:s,calls:t,text:r,images:d}=E(o.content);if(Number(o.role)===C)return[{role:"system",content:r}];if(o.role===l.LanguageModelChatMessageRole.Assistant){let a=t.map(i=>({id:i.callId,type:"function",function:{name:i.name,arguments:JSON.stringify(i.input)}}));return r||a.length?[{role:"assistant",content:r||null,...a.length?{tool_calls:a}:{}}]:[]}let c=[...r?[{type:"text",text:r}]:[],...[...s.flatMap(a=>A(a.content)),...d].map(a=>({type:"image_url",image_url:{url:a}}))];return[...s.map(a=>({role:"tool",tool_call_id:a.callId,content:a.content.map(w).join("")})),...c.length?[{role:"user",content:c}]:[]]});return{model:e.model,messages:n,stream:!0,...q(e,o=>({type:"function",function:{name:o.name,description:o.description,parameters:T(o)}}))}}var ye=e=>typeof e=="string"?e:(e??[]).filter(n=>n.type==="text").map(n=>n.text??"").join(""),ve=new Set(["stop","tool_calls","function_call"]);async function*F(e){let n=[],o;for await(let s of e){let{error:t}=s;if(t)throw new Error(typeof t=="string"?t:t.message??JSON.stringify(t));for(let r of s.choices??[]){let d=ye(r.delta?.content);d&&(yield new l.LanguageModelTextPart(d));for(let c of r.delta?.tool_calls??[]){let a=n[c.index??0]??={id:"",name:"",args:""};a.id=c.id||a.id,a.name=c.function?.name||a.name,a.args+=c.function?.arguments??""}if(r.finish_reason&&(o=r.finish_reason,!ve.has(o)))throw S(o)}}if(!o)throw P();for(let s of n.filter(Boolean))yield R(s.id,s.name,s.args)}var we=e=>{let n=e.map(w).join(""),o=A(e);return o.length?[...n?[{type:"input_text",text:n}]:[],...o.map(s=>({type:"input_image",image_url:s,detail:"auto"}))]:n};function H(e){let n=[],o=e.messages.flatMap(s=>{let{results:t,calls:r,text:d,images:c}=E(s.content);if(Number(s.role)===C)return n.push(d),[];if(s.role===l.LanguageModelChatMessageRole.Assistant)return[...d?[{role:"assistant",content:d}]:[],...r.map(i=>({type:"function_call",call_id:i.callId,name:i.name,arguments:JSON.stringify(i.input)}))];let a=[...d?[{type:"input_text",text:d}]:[],...c.map(i=>({type:"input_image",image_url:i,detail:"auto"}))];return[...t.map(i=>({type:"function_call_output",call_id:i.callId,output:we(i.content)})),...a.length?[{role:"user",content:a}]:[]]});return{model:e.model,...n.length?{instructions:n.join(` +"use strict";var se=Object.create;var b=Object.defineProperty;var ae=Object.getOwnPropertyDescriptor;var re=Object.getOwnPropertyNames;var ie=Object.getPrototypeOf,ce=Object.prototype.hasOwnProperty;var le=(e,n)=>{for(var o in n)b(e,o,{get:n[o],enumerable:!0})},j=(e,n,o,s)=>{if(n&&typeof n=="object"||typeof n=="function")for(let t of re(n))!ce.call(e,t)&&t!==o&&b(e,t,{get:()=>n[t],enumerable:!(s=ae(n,t))||s.enumerable});return e};var L=(e,n,o)=>(o=e!=null?se(ie(e)):{},j(n||!e||!e.__esModule?b(o,"default",{value:e,enumerable:!0}):o,e)),de=e=>j(b({},"__esModule",{value:!0}),e);var Te={};le(Te,{CONFIG_FILE:()=>Q,DatabricksModels:()=>k,PROVIDER_HEADER:()=>X,activate:()=>Ce,readConfig:()=>Z,userAgent:()=>ee});module.exports=de(Te);var z=require("node:fs"),V=require("node:path"),h=L(require("vscode"));var M="0.1.0";var I=require("node:child_process"),pe=5*6e4,ue=60*6e4,me=e=>new Promise((n,o)=>{(0,I.exec)(e,{timeout:6e4,windowsHide:!0},(s,t,r)=>{s?o(new Error(`Couldn't get a Databricks token: \`${e}\` failed: ${String(r).trim().slice(0,500)||s.message}`)):n(String(t).trim())})}),fe=e=>{try{let{exp:n}=JSON.parse(Buffer.from(e.split(".")[1]??"","base64url").toString());if(typeof n=="number")return n*1e3}catch{}return Date.now()+ue},x=class{constructor(n,o=()=>{}){this.command=n;this.onFetch=o}command;onFetch;cached;pending;get(){return this.cached&&this.cached.expiresAt-pe>Date.now()?Promise.resolve(this.cached.value):this.fetch(this.command)}async renew(n){return await this.pending?.catch(()=>{}),this.cached&&this.cached.value!==n?this.cached.value:(this.cached=void 0,this.fetch(`${this.command} --force-refresh`))}fetch(n){return this.pending??=me(n).then(o=>{if(!o)throw new Error(`Couldn't get a Databricks token: \`${n}\` printed nothing.`);return this.cached={value:o,expiresAt:fe(o)},this.onFetch(this.cached.expiresAt),o}).finally(()=>{this.pending=void 0}),this.pending}};var l=L(require("vscode")),C=3,he=1e3,v=e=>e instanceof l.LanguageModelTextPart?e.value:e instanceof l.LanguageModelDataPart&&!e.mimeType.startsWith("image/")?new TextDecoder().decode(e.data):e instanceof l.LanguageModelPromptTsxPart?JSON.stringify(e.value):"",D=e=>e instanceof l.LanguageModelDataPart&&e.mimeType.startsWith("image/")?{mimeType:e.mimeType,data:Buffer.from(e.data).toString("base64")}:void 0,$=e=>{let n=D(e);return n&&`data:${n.mimeType};base64,${n.data}`},T=e=>e.inputSchema??{type:"object",properties:{}},R=(e,n,o)=>{let s;try{s=o.trim()?JSON.parse(o):{}}catch{s=void 0}if(typeof s!="object"||s===null||Array.isArray(s))throw new Error(`The model called ${n} with arguments that aren't a JSON object: ${o}`);return new l.LanguageModelToolCallPart(e,n,s)},S=e=>e==="max_output_tokens"||e==="length"||e==="max_tokens"?new Error("The model stopped at its output token limit before finishing the response."):e==="content_filter"||e==="refusal"?new Error("The model's response was stopped by a content filter."):new Error(`The model stopped before finishing the response (${e??"no reason given"}).`),P=()=>new Error("The response ended before the model finished. Try again."),q=(e,n)=>e.tools.length?{tools:e.tools.map(n),...e.toolRequired?{tool_choice:"required"}:{}}:{},G=e=>{if(typeof e=="string")return Math.ceil(e.length/4);let n=0,o=0;for(let s of e.content){let t=s instanceof l.LanguageModelToolResultPart?s.content:[s];for(let r of t)n+=r instanceof l.LanguageModelToolCallPart?r.name.length+JSON.stringify(r.input).length:v(r).length,o+=$(r)===void 0?0:1}return Math.ceil(n/4)+o*he},A=e=>e.map($).filter(n=>n!==void 0),E=e=>({results:e.filter(n=>n instanceof l.LanguageModelToolResultPart),calls:e.filter(n=>n instanceof l.LanguageModelToolCallPart),text:e.map(v).join(""),images:A(e)});function J(e){let n=e.messages.flatMap(o=>{let{results:s,calls:t,text:r,images:d}=E(o.content);if(Number(o.role)===C)return[{role:"system",content:r}];if(o.role===l.LanguageModelChatMessageRole.Assistant){let a=t.map(i=>({id:i.callId,type:"function",function:{name:i.name,arguments:JSON.stringify(i.input)}}));return r||a.length?[{role:"assistant",content:r||null,...a.length?{tool_calls:a}:{}}]:[]}let c=[...r?[{type:"text",text:r}]:[],...[...s.flatMap(a=>A(a.content)),...d].map(a=>({type:"image_url",image_url:{url:a}}))];return[...s.map(a=>({role:"tool",tool_call_id:a.callId,content:a.content.map(v).join("")})),...c.length?[{role:"user",content:c}]:[]]});return{model:e.model,messages:n,stream:!0,...q(e,o=>({type:"function",function:{name:o.name,description:o.description,parameters:T(o)}}))}}var ye=e=>typeof e=="string"?e:(e??[]).filter(n=>n.type==="text").map(n=>n.text??"").join(""),we=new Set(["stop","tool_calls","function_call"]);async function*F(e){let n=[],o;for await(let s of e){let{error:t}=s;if(t)throw new Error(typeof t=="string"?t:t.message??JSON.stringify(t));for(let r of s.choices??[]){let d=ye(r.delta?.content);d&&(yield new l.LanguageModelTextPart(d));for(let c of r.delta?.tool_calls??[]){let a=n[c.index??0]??={id:"",name:"",args:""};a.id=c.id||a.id,a.name=c.function?.name||a.name,a.args+=c.function?.arguments??""}if(r.finish_reason&&(o=r.finish_reason,!we.has(o)))throw S(o)}}if(!o)throw P();for(let s of n.filter(Boolean))yield R(s.id,s.name,s.args)}var ve=e=>{let n=e.map(v).join(""),o=A(e);return o.length?[...n?[{type:"input_text",text:n}]:[],...o.map(s=>({type:"input_image",image_url:s,detail:"auto"}))]:n};function H(e){let n=[],o=e.messages.flatMap(s=>{let{results:t,calls:r,text:d,images:c}=E(s.content);if(Number(s.role)===C)return n.push(d),[];if(s.role===l.LanguageModelChatMessageRole.Assistant)return[...d?[{role:"assistant",content:d}]:[],...r.map(i=>({type:"function_call",call_id:i.callId,name:i.name,arguments:JSON.stringify(i.input)}))];let a=[...d?[{type:"input_text",text:d}]:[],...c.map(i=>({type:"input_image",image_url:i,detail:"auto"}))];return[...t.map(i=>({type:"function_call_output",call_id:i.callId,output:ve(i.content)})),...a.length?[{role:"user",content:a}]:[]]});return{model:e.model,...n.length?{instructions:n.join(` -`)}:{},input:o,stream:!0,store:!1,...q(e,s=>({type:"function",name:s.name,description:s.description,parameters:T(s),strict:!1}))}}async function*U(e){let n=[],o=!1;for await(let s of e){let t=s;if(t.type==="response.output_text.delta"&&t.delta)yield new l.LanguageModelTextPart(t.delta);else if(t.type==="response.output_item.done"&&t.item?.type==="function_call")n.push(R(t.item.call_id??"",t.item.name??"",t.item.arguments??""));else if(t.type==="response.completed")o=!0;else{if(t.type==="response.incomplete")throw S(t.response?.incomplete_details?.reason);if(t.type==="response.failed"||t.type==="error")throw new Error(t.response?.error?.message??t.message??"The model response failed.")}}if(!o)throw P();yield*n}var _=e=>e?[{type:"text",text:e}]:[],O=e=>e.flatMap(n=>{let o=D(n);return o?[{type:"image",source:{type:"base64",media_type:o.mimeType,data:o.data}}]:[]}),be={cache_control:{type:"ephemeral"}},N=e=>{e.length&&(e[e.length-1]={...e[e.length-1],...be})};function B(e,n){let o=[],s=[];for(let t of e.messages){let{results:r,calls:d,text:c}=E(t.content);if(Number(t.role)===C){o.push(..._(c));continue}let a=t.role===l.LanguageModelChatMessageRole.Assistant?"assistant":"user",i=a==="assistant"?[..._(c),...d.map(g=>({type:"tool_use",id:g.callId,name:g.name,input:g.input}))]:[...r.map(g=>{let p=[..._(g.content.map(w).join("")),...O(g.content)];return{type:"tool_result",tool_use_id:g.callId,...p.length?{content:p}:{}}}),..._(c),...O(t.content)],u=s[s.length-1];if(i.length)u?.role===a?u.content.push(...i):s.push({role:a,content:i});else continue}return N(o),N(s[s.length-1]?.content??[]),{model:e.model,max_tokens:n,...o.length?{system:o}:{},messages:s,stream:!0,...e.tools.length?{tools:e.tools.map(t=>({name:t.name,description:t.description,input_schema:T(t)})),...e.toolRequired?{tool_choice:{type:"any"}}:{}}:{}}}var xe=new Set(["end_turn","tool_use","stop_sequence"]);async function*W(e){let n=new Map,o=!1;for await(let s of e){let t=s;if(t.type==="content_block_start"&&t.content_block?.type==="tool_use")n.set(t.index??0,{id:t.content_block.id??"",name:t.content_block.name??"",args:""});else if(t.type==="content_block_delta"&&t.delta?.type==="text_delta"&&t.delta.text)yield new l.LanguageModelTextPart(t.delta.text);else if(t.type==="content_block_delta"&&t.delta?.type==="input_json_delta"){let r=n.get(t.index??0);r&&(r.args+=t.delta.partial_json??"")}else{if(t.type==="message_delta"&&t.delta?.stop_reason&&!xe.has(t.delta.stop_reason))throw S(t.delta.stop_reason);if(t.type==="message_stop")o=!0;else if(t.type==="error")throw new Error(t.error?.message??"The model response failed.")}}if(!o)throw P();for(let s of n.values())yield R(s.id,s.name,s.args)}async function*Y(e,n,o,s,t,r){let d=(p,f)=>fetch(`${e}${n}`,{method:"POST",headers:{...t,Authorization:`Bearer ${p}`,"Content-Type":"application/json"},body:JSON.stringify(f),signal:r}),c=await s.get(),a=await d(c,o);if(a.status===401&&(await a.body?.cancel(),c=await s.renew(c),a=await d(c,o)),a.status===401)throw await a.body?.cancel(),new Error("Databricks rejected the sign-in. Run `ug configure --agents vscode` to sign in again, then retry.");if(!a.ok||!a.body){let p=(await a.text()).slice(0,300),f=Number(/max_tokens: \d+ > (\d+)/.exec(p)?.[1]);if(!f)throw new Error(`AI Gateway returned ${a.status}: ${p}`);if(a=await d(c,{...o,max_tokens:f}),!a.ok||!a.body)throw new Error(`AI Gateway returned ${a.status}: ${(await a.text()).slice(0,300)}`)}let i=new TextDecoder,u="";for await(let p of a.body){u+=i.decode(p,{stream:!0});let f=u.split(` -`);u=f.pop()??"";for(let v of f){let y=K(v);if(y==="[DONE]")return;y&&(yield JSON.parse(y))}}let g=K(u+i.decode());g&&g!=="[DONE]"&&(yield JSON.parse(g))}var K=e=>e.startsWith("data:")?e.slice(5).trim():"";var _e=["anthropic","completions","responses"],X="Databricks-Model-Provider-Service",ke=e=>Object.fromEntries(["maxInputTokens","maxOutputTokens"].flatMap(n=>{let o=e[n];return typeof o=="number"&&Number.isInteger(o)&&o>0?[[n,o]]:[]})),Q="ug-config.json",Z=e=>{let n={};try{let t=JSON.parse(e??"{}");t&&typeof t=="object"&&!Array.isArray(t)&&(n=t)}catch{}let o=t=>n[t],s=o("models");return{workspace:String(o("workspace")??"").replace(/\/+$/,""),models:(Array.isArray(s)?s:[]).filter(t=>typeof t?.id=="string"&&t.id!==""&&_e.includes(t.api)).map(t=>({id:t.id,...typeof t.name=="string"&&t.name.trim()?{name:t.name.trim()}:{},...typeof t.family=="string"&&t.family?{family:t.family}:{},api:t.api,...ke(t)})),authCommand:String(o("authCommand")??""),provider:typeof o("provider")=="string"?String(o("provider")):""}},ee=e=>`ucode/${M} githubcopilotchat${typeof e=="string"?`/${e}`:""}`,k=class{constructor(n,o,s=()=>({})){this.settings=n;this.log=o;this.headers=s}settings;log;headers;tokens;async provideLanguageModelChatInformation(){let{workspace:n,models:o,authCommand:s}=this.settings();return!n||!s||o.length===0?(this.log("No Databricks models: run `ug configure --agents vscode` to set them up."),[]):o.map(({id:t,name:r,family:d,api:c,maxInputTokens:a,maxOutputTokens:i})=>({id:t,api:c,name:r??t,family:d??t.split(".").pop()??t,version:M,detail:"Databricks",maxInputTokens:a??128e3,maxOutputTokens:i??32e3,capabilities:{toolCalling:!0,imageInput:c!=="completions"||t.includes("claude")}}))}async provideLanguageModelChatResponse(n,o,s,t,r){let{workspace:d,authCommand:c,provider:a}=this.settings();this.tokens??=new x(c,m=>this.log(`Fetched a Databricks token (expires ${new Date(m).toISOString()})`));let i={model:n.id,messages:o,tools:s.tools??[],toolRequired:s.toolMode===h.LanguageModelChatToolMode.Required},u=new AbortController,g=r.onCancellationRequested(()=>u.abort()),p={...this.headers(),...a?{[X]:a}:{}},f=this.tokens,v=(m,ne,oe={})=>Y(d,m,ne,f,{...p,...oe},u.signal),y=a?"/ai-gateway/openai/v1":"/ai-gateway/mlflow/v1",te={anthropic:()=>W(v("/ai-gateway/anthropic/v1/messages",B(i,n.maxOutputTokens),{"anthropic-version":"2023-06-01"})),responses:()=>U(v(`${y}/responses`,H(i))),completions:()=>F(v(`${y}/chat/completions`,J(i)))};try{for await(let m of te[n.api]())t.report(m)}catch(m){if(!r.isCancellationRequested)throw this.log(`${n.id} failed: ${m instanceof Error?m.message:String(m)}`),m}finally{g.dispose()}}async provideTokenCount(n,o){return G(o)}},Me=e=>{try{return(0,z.readFileSync)(e,"utf8")}catch{return}};function Ce(e){let n=h.window.createOutputChannel("Unity Gateway",{log:!0}),o=Z(Me((0,V.join)(e.extensionPath,Q))),s=()=>({"User-Agent":ee(h.extensions.getExtension("GitHub.copilot-chat")?.packageJSON?.version)}),t=new k(()=>o,r=>n.info(r),s);e.subscriptions.push(n,h.lm.registerLanguageModelChatProvider("databricks-unity-gateway",t))}0&&(module.exports={CONFIG_FILE,DatabricksModels,PROVIDER_HEADER,activate,readConfig,userAgent}); +`)}:{},input:o,stream:!0,store:!1,...q(e,s=>({type:"function",name:s.name,description:s.description,parameters:T(s),strict:!1}))}}async function*U(e){let n=[],o=!1;for await(let s of e){let t=s;if(t.type==="response.output_text.delta"&&t.delta)yield new l.LanguageModelTextPart(t.delta);else if(t.type==="response.output_item.done"&&t.item?.type==="function_call")n.push(R(t.item.call_id??"",t.item.name??"",t.item.arguments??""));else if(t.type==="response.completed")o=!0;else{if(t.type==="response.incomplete")throw S(t.response?.incomplete_details?.reason);if(t.type==="response.failed"||t.type==="error")throw new Error(t.response?.error?.message??t.message??"The model response failed.")}}if(!o)throw P();yield*n}var _=e=>e?[{type:"text",text:e}]:[],O=e=>e.flatMap(n=>{let o=D(n);return o?[{type:"image",source:{type:"base64",media_type:o.mimeType,data:o.data}}]:[]}),be={cache_control:{type:"ephemeral"}},N=e=>{e.length&&(e[e.length-1]={...e[e.length-1],...be})};function B(e,n){let o=[],s=[];for(let t of e.messages){let{results:r,calls:d,text:c}=E(t.content);if(Number(t.role)===C){o.push(..._(c));continue}let a=t.role===l.LanguageModelChatMessageRole.Assistant?"assistant":"user",i=a==="assistant"?[..._(c),...d.map(g=>({type:"tool_use",id:g.callId,name:g.name,input:g.input}))]:[...r.map(g=>{let p=[..._(g.content.map(v).join("")),...O(g.content)];return{type:"tool_result",tool_use_id:g.callId,...p.length?{content:p}:{}}}),..._(c),...O(t.content)],u=s[s.length-1];if(i.length)u?.role===a?u.content.push(...i):s.push({role:a,content:i});else continue}return N(o),N(s[s.length-1]?.content??[]),{model:e.model,max_tokens:n,...o.length?{system:o}:{},messages:s,stream:!0,...e.tools.length?{tools:e.tools.map(t=>({name:t.name,description:t.description,input_schema:T(t)})),...e.toolRequired?{tool_choice:{type:"any"}}:{}}:{}}}var xe=new Set(["end_turn","tool_use","stop_sequence"]);async function*W(e){let n=new Map,o=!1;for await(let s of e){let t=s;if(t.type==="content_block_start"&&t.content_block?.type==="tool_use")n.set(t.index??0,{id:t.content_block.id??"",name:t.content_block.name??"",args:""});else if(t.type==="content_block_delta"&&t.delta?.type==="text_delta"&&t.delta.text)yield new l.LanguageModelTextPart(t.delta.text);else if(t.type==="content_block_delta"&&t.delta?.type==="input_json_delta"){let r=n.get(t.index??0);r&&(r.args+=t.delta.partial_json??"")}else{if(t.type==="message_delta"&&t.delta?.stop_reason&&!xe.has(t.delta.stop_reason))throw S(t.delta.stop_reason);if(t.type==="message_stop")o=!0;else if(t.type==="error")throw new Error(t.error?.message??"The model response failed.")}}if(!o)throw P();for(let s of n.values())yield R(s.id,s.name,s.args)}async function*Y(e,n,o,s,t,r){let d=(p,f)=>fetch(`${e}${n}`,{method:"POST",headers:{...t,Authorization:`Bearer ${p}`,"Content-Type":"application/json"},body:JSON.stringify(f),signal:r}),c=await s.get(),a=await d(c,o);if(a.status===401&&(await a.body?.cancel(),c=await s.renew(c),a=await d(c,o)),a.status===401)throw await a.body?.cancel(),new Error("Databricks rejected the sign-in. Run `ug configure --agents copilot` to sign in again, then retry.");if(!a.ok||!a.body){let p=(await a.text()).slice(0,300),f=Number(/max_tokens: \d+ > (\d+)/.exec(p)?.[1]);if(!f)throw new Error(`AI Gateway returned ${a.status}: ${p}`);if(a=await d(c,{...o,max_tokens:f}),!a.ok||!a.body)throw new Error(`AI Gateway returned ${a.status}: ${(await a.text()).slice(0,300)}`)}let i=new TextDecoder,u="";for await(let p of a.body){u+=i.decode(p,{stream:!0});let f=u.split(` +`);u=f.pop()??"";for(let w of f){let y=K(w);if(y==="[DONE]")return;y&&(yield JSON.parse(y))}}let g=K(u+i.decode());g&&g!=="[DONE]"&&(yield JSON.parse(g))}var K=e=>e.startsWith("data:")?e.slice(5).trim():"";var _e=["anthropic","completions","responses"],X="Databricks-Model-Provider-Service",ke=e=>Object.fromEntries(["maxInputTokens","maxOutputTokens"].flatMap(n=>{let o=e[n];return typeof o=="number"&&Number.isInteger(o)&&o>0?[[n,o]]:[]})),Q="ug-config.json",Z=e=>{let n={};try{let t=JSON.parse(e??"{}");t&&typeof t=="object"&&!Array.isArray(t)&&(n=t)}catch{}let o=t=>n[t],s=o("models");return{workspace:String(o("workspace")??"").replace(/\/+$/,""),models:(Array.isArray(s)?s:[]).filter(t=>typeof t?.id=="string"&&t.id!==""&&_e.includes(t.api)).map(t=>({id:t.id,...typeof t.name=="string"&&t.name.trim()?{name:t.name.trim()}:{},...typeof t.family=="string"&&t.family?{family:t.family}:{},api:t.api,...ke(t)})),authCommand:String(o("authCommand")??""),provider:typeof o("provider")=="string"?String(o("provider")):""}},ee=e=>`ucode/${M} githubcopilotchat${typeof e=="string"?`/${e}`:""}`,k=class{constructor(n,o,s=()=>({})){this.settings=n;this.log=o;this.headers=s}settings;log;headers;tokens;async provideLanguageModelChatInformation(){let{workspace:n,models:o,authCommand:s}=this.settings();return!n||!s||o.length===0?(this.log("No Databricks models: run `ug configure --agents copilot` to set them up."),[]):o.map(({id:t,name:r,family:d,api:c,maxInputTokens:a,maxOutputTokens:i})=>({id:t,api:c,name:r??t,family:d??t.split(".").pop()??t,version:M,detail:"Databricks",maxInputTokens:a??128e3,maxOutputTokens:i??32e3,capabilities:{toolCalling:!0,imageInput:c!=="completions"||t.includes("claude")}}))}async provideLanguageModelChatResponse(n,o,s,t,r){let{workspace:d,authCommand:c,provider:a}=this.settings();this.tokens??=new x(c,m=>this.log(`Fetched a Databricks token (expires ${new Date(m).toISOString()})`));let i={model:n.id,messages:o,tools:s.tools??[],toolRequired:s.toolMode===h.LanguageModelChatToolMode.Required},u=new AbortController,g=r.onCancellationRequested(()=>u.abort()),p={...this.headers(),...a?{[X]:a}:{}},f=this.tokens,w=(m,ne,oe={})=>Y(d,m,ne,f,{...p,...oe},u.signal),y=a?"/ai-gateway/openai/v1":"/ai-gateway/mlflow/v1",te={anthropic:()=>W(w("/ai-gateway/anthropic/v1/messages",B(i,n.maxOutputTokens),{"anthropic-version":"2023-06-01"})),responses:()=>U(w(`${y}/responses`,H(i))),completions:()=>F(w(`${y}/chat/completions`,J(i)))};try{for await(let m of te[n.api]())t.report(m)}catch(m){if(!r.isCancellationRequested)throw this.log(`${n.id} failed: ${m instanceof Error?m.message:String(m)}`),m}finally{g.dispose()}}async provideTokenCount(n,o){return G(o)}},Me=e=>{try{return(0,z.readFileSync)(e,"utf8")}catch{return}};function Ce(e){let n=h.window.createOutputChannel("Unity Gateway",{log:!0}),o=Z(Me((0,V.join)(e.extensionPath,Q))),s=()=>({"User-Agent":ee(h.extensions.getExtension("GitHub.copilot-chat")?.packageJSON?.version)}),t=new k(()=>o,r=>n.info(r),s);e.subscriptions.push(n,h.lm.registerLanguageModelChatProvider("databricks-unity-gateway",t))}0&&(module.exports={CONFIG_FILE,DatabricksModels,PROVIDER_HEADER,activate,readConfig,userAgent}); diff --git a/tests/conftest.py b/tests/conftest.py index 558e3b624..784a9e059 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,6 +41,7 @@ def _isolate_ucode_state(tmp_path, monkeypatch): it can never touch the developer's real ~/.ucode/state.json or invoke the privileged writer for an OS-managed agent config. """ + import ucode.agents as agents_mod import ucode.config_io as config_io_mod import ucode.databricks as databricks_mod import ucode.managed_config as managed_config_mod @@ -61,6 +62,7 @@ def _isolate_ucode_state(tmp_path, monkeypatch): monkeypatch.setattr(config_io_mod, "APP_DIR", state_dir) monkeypatch.setattr(codex_mod, "_otel_service_path", lambda: state_dir / "otel.service") monkeypatch.setattr(desktop_mod, "APP_DIR", state_dir) + monkeypatch.setattr(agents_mod, "_vscode_with_copilot", True) # No test may run the developer's `code` or look at their installed VS Code extensions. monkeypatch.setattr(vscode_mod, "find_code", lambda: None) monkeypatch.setattr(vscode_mod, "_find_command", lambda command: None) diff --git a/tests/test_agent_vscode.py b/tests/test_agent_vscode.py index a5dd7b5da..e72529176 100644 --- a/tests/test_agent_vscode.py +++ b/tests/test_agent_vscode.py @@ -1,4 +1,4 @@ -"""Tests for `ug configure --agents vscode` and its revert.""" +"""Tests for Copilot Chat in VS Code: `ug configure --agents copilot` sets it up, and revert.""" from __future__ import annotations @@ -10,6 +10,8 @@ import pytest from typer.testing import CliRunner +import ucode.agents as agents_mod +import ucode.cli as cli_mod from ucode import config_io from ucode import databricks as databricks_module from ucode.agents import copilot, vscode @@ -449,86 +451,157 @@ def test_pat_and_custom_oauth(self): assert "--profile custom" in vscode.auth_command({"workspace": WS, "custom_oauth": custom}) -def test_configure_agents_vscode_refuses_a_workspace_the_machine_isnt_managed_for(monkeypatch): - selected: list[str] = [] - monkeypatch.setattr( - "ucode.cli._configure_shared_workspace_states", lambda *args, **kwargs: [STATE] - ) - monkeypatch.setattr("ucode.cli.load_state", lambda: STATE) +def _stub_copilot_setup(monkeypatch) -> list[dict]: + """Make the Copilot CLI setup a no-op and record each VS Code setup's model source.""" + configured: list[dict] = [] + monkeypatch.setattr(agents_mod, "_configure_one", lambda tool, state, *a, **k: state) monkeypatch.setattr( - "ucode.cli.managed_state_workspace", lambda: "https://managed.cloud.databricks.com" + vscode, "configure", lambda state, **source: configured.append(source) or True ) - monkeypatch.setattr("ucode.cli.set_current_workspace", selected.append) - monkeypatch.setattr(vscode, "configure", lambda state, **_: pytest.fail("VS Code was set up")) + return configured - result = CliRunner().invoke(app, ["configure", "--agents", "vscode", "--workspace", WS]) - assert result.exit_code == 1 - assert "This machine is managed for https://managed.cloud.databricks.com" in " ".join( - result.output.split() - ) - assert selected == ["https://managed.cloud.databricks.com"] +@pytest.mark.parametrize( + ("saved", "source"), + [ + ({}, {"provider": None, "location": None}), + ( + {"provider_services": {"copilot": "main.default.mps"}}, + {"provider": "main.default.mps", "location": None}, + ), + ( + {"model_locations": {"copilot": "main.models"}}, + {"provider": None, "location": "main.models"}, + ), + ], +) +def test_configuring_copilot_sets_up_vscode_with_the_same_models(monkeypatch, saved, source): + configured = _stub_copilot_setup(monkeypatch) + + agents_mod.configure_selected_tools({**STATE, **saved}, ["claude", "copilot"]) + + assert configured == [source] -@pytest.mark.parametrize("dry_run", [False, True]) -def test_configure_agents_vscode_sets_up_the_workspace_then_vscode(monkeypatch, dry_run): +def test_a_failed_copilot_setup_leaves_vscode_alone(monkeypatch): + def fail(*args, **kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(agents_mod, "_configure_one", fail) + monkeypatch.setattr(vscode, "configure", lambda *a, **k: pytest.fail("VS Code was set up")) + + state = agents_mod.configure_selected_tools(dict(STATE), ["copilot"]) + + assert state["last_configured_tools"] == [] + + +def test_configuring_copilot_alone_sets_up_vscode_only_from_configure(monkeypatch): + configured = _stub_copilot_setup(monkeypatch) + monkeypatch.setattr(agents_mod, "check_gateway_endpoint", lambda *args: True) + + # A launch that configures copilot first leaves VS Code alone; `ug configure --agent` doesn't. + agents_mod.configure_single_tool("copilot", dict(STATE)) + agents_mod.configure_single_tool("copilot", dict(STATE), with_vscode=True) + + assert configured == [{"provider": None, "location": None}] + + +@pytest.mark.parametrize("skip", ["no_vscode", "dry_run"]) +def test_no_vscode_and_a_dry_run_leave_vscode_alone(monkeypatch, capsys, skip): # `--dry-run` sets a module-wide flag; this puts it back so later tests write files again. - monkeypatch.setattr(config_io, "_dry_run", False) - configured: list[dict] = [] - shared: list[dict] = [] + monkeypatch.setattr(config_io, "_dry_run", skip == "dry_run") + agents_mod.request_vscode_with_copilot(skip != "no_vscode") + monkeypatch.setattr(vscode, "configure", lambda *a, **k: pytest.fail("VS Code was set up")) + + agents_mod.configure_vscode(STATE, provider=None, parent_schema=None) + + assert ("VS Code isn't changed" in capsys.readouterr().out) == (skip == "dry_run") + + +def test_configure_passes_copilot_its_model_source_and_no_vscode(monkeypatch): + calls: list[dict] = [] + monkeypatch.setattr("ucode.cli.install_databricks_cli", lambda: None) monkeypatch.setattr( - "ucode.cli._configure_shared_workspace_states", - lambda *args, **kwargs: shared.append(kwargs) or [STATE], + "ucode.cli.configure_workspace_command", lambda **kwargs: calls.append(kwargs) or 0 ) - monkeypatch.setattr( - "ucode.cli.configure_workspace_command", - lambda **kwargs: pytest.fail("no model agents to configure"), + + result = CliRunner().invoke( + app, + [ + "configure", + "--agents", + "copilot", + "--workspace", + WS, + "--provider", + "main.default.mps", + "--no-vscode", + ], ) - monkeypatch.setattr("ucode.cli.load_state", lambda: STATE) + + assert result.exit_code == 0, result.output + assert calls[0]["selected_tools"] == ["copilot"] + assert calls[0]["copilot_source"] == ("main.default.mps", None) + assert agents_mod._vscode_with_copilot is False + + +def _stub_workspace_setup(monkeypatch, state: dict, managed: dict | None) -> list[dict]: + seen: list[dict] = [] + monkeypatch.setattr(cli_mod, "_configure_shared_workspace_states", lambda *a, **k: [state]) + monkeypatch.setattr(cli_mod, "_refresh_configured_policy", lambda s: (managed, False)) + monkeypatch.setattr(cli_mod, "check_gateway_endpoint", lambda *args: True) + monkeypatch.setattr(cli_mod, "install_tool_binary", lambda *a, **k: True) monkeypatch.setattr( - vscode, - "configure", - lambda state, **source: configured.append({**state, **source}) or True, + cli_mod, "configure_selected_tools", lambda s, tools, **k: seen.append(dict(s)) or s ) + return seen - args = [ - "configure", - "--agents", - "vscode", - "--workspace", - WS, - "--provider", - "main.default.mps", - *(["--dry-run"] if dry_run else []), - ] - result = CliRunner().invoke(app, args) - assert result.exit_code == 0, result.output - assert shared[0]["tools"] == [] - # A dry run saves no workspace, so VS Code would be set up from stale or missing state. - source = {"provider": "main.default.mps", "location": None} - assert configured == ([] if dry_run else [{**STATE, **source}]) - assert ("VS Code isn't changed" in result.output) == dry_run +def test_configure_saves_copilots_model_source_for_its_launches(monkeypatch): + state = {**STATE, "provider_services": {"copilot": "old.default.mps"}} + seen = _stub_workspace_setup(monkeypatch, state, None) + + cli_mod.configure_workspace_command( + selected_tools=["copilot"], workspaces=[(WS, None)], copilot_source=(None, "main.models") + ) + + assert "provider_services" not in seen[0] + assert seen[0]["model_locations"] == {"copilot": "main.models"} + + +def test_configure_refuses_a_copilot_model_source_on_a_managed_workspace(monkeypatch): + seen = _stub_workspace_setup( + monkeypatch, dict(STATE), {"enabled_agents": {"copilot": {"model_config": {}}}} + ) + + with pytest.raises(RuntimeError, match="managed configuration"): + cli_mod.configure_workspace_command( + selected_tools=["copilot"], + workspaces=[(WS, None)], + copilot_source=("main.default.mps", None), + ) + + assert seen == [] @pytest.mark.parametrize( ("args", "error"), [ - (["--agents", "claude", "--provider", "a.b.c"], "work only with `--agents vscode`"), - (["--provider", "a.b.c"], "work only with `--agents vscode`"), - # A model agent alongside VS Code would silently keep the workspace's own models. + (["--agents", "claude", "--provider", "a.b.c"], "work only with `--agents copilot`"), + (["--provider", "a.b.c"], "work only with `--agents copilot`"), + # Another model agent alongside Copilot would silently keep the workspace's own models. ( - ["--agents", "claude,vscode", "--provider", "a.b.c"], - "work only with `--agents vscode`", + ["--agents", "claude,copilot", "--provider", "a.b.c"], + "work only with `--agents copilot`", ), ( - ["--agents", "vscode", "--provider", "a.b.c", "--model-location", "a.b"], + ["--agents", "copilot", "--provider", "a.b.c", "--model-location", "a.b"], "cannot be used together", ), - (["--agents", "vscode", "--model-location", "a.b.c"], "must be `.`"), + (["--agents", "copilot", "--model-location", "a.b.c"], "must be `.`"), ], ) -def test_configure_refuses_a_model_source_vscode_cant_take(monkeypatch, args, error): +def test_configure_refuses_a_model_source_copilot_cant_take(monkeypatch, args, error): monkeypatch.setattr("ucode.cli.install_databricks_cli", lambda: None) monkeypatch.setattr( "ucode.cli._configure_shared_workspace_states", @@ -539,3 +612,12 @@ def test_configure_refuses_a_model_source_vscode_cant_take(monkeypatch, args, er assert result.exit_code != 0 assert error in " ".join(result.output.split()) + + +def test_vscode_is_not_an_agent_of_its_own(monkeypatch): + monkeypatch.setattr("ucode.cli.install_databricks_cli", lambda: None) + + result = CliRunner().invoke(app, ["configure", "--agents", "vscode", "--workspace", WS]) + + assert result.exit_code != 0 + assert "Unsupported tool 'vscode'" in " ".join(result.output.split()) diff --git a/tests/test_cli.py b/tests/test_cli.py index d8e30d5c1..0c8eb9807 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2867,7 +2867,7 @@ def test_status_treats_available_tools_as_configured_agents(self): output = re.sub(r"\s+", " ", _strip_ansi(result.output)) assert result.exit_code == 0, result.output assert "MCP servers: 1" in output - assert "GitHub Copilot CLI" in output + assert "GitHub Copilot (CLI and VS Code)" in output assert "Claude Code" not in output assert "Gemini CLI" not in output assert "databricks-sql" not in result.output @@ -5493,7 +5493,9 @@ def test_single_claude_agent_passes_managed_uc_parent_directly(self, monkeypatch cli_mod.configure_workspace_command(tool="claude", workspaces=[("https://w.com", None)]) == 0 ) - configure.assert_called_once_with("claude", state, parent_schema="main.models") + configure.assert_called_once_with( + "claude", state, parent_schema="main.models", with_vscode=True + ) def test_managed_codex_parent_is_passed_to_generic_configure(self, monkeypatch): state = { @@ -5551,7 +5553,9 @@ def test_single_codex_agent_passes_managed_parent_directly(self, monkeypatch): assert result.exit_code == 0, result.output assert "(Provider: Databricks)" in _strip_ansi(result.output) refresh.assert_called_once_with(state, force_refresh=True) - configure.assert_called_once_with("codex", state, parent_schema="main.models") + configure.assert_called_once_with( + "codex", state, parent_schema="main.models", with_vscode=True + ) install_ai_tools.assert_called_once_with(["codex"], state, force_refresh=False) def test_managed_copilot_parent_is_passed_to_generic_configure(self, monkeypatch): @@ -5614,7 +5618,9 @@ def test_single_copilot_agent_passes_managed_parent_directly(self, monkeypatch): assert result.exit_code == 0, result.output assert "(Provider: Databricks)" in _strip_ansi(result.output) refresh.assert_called_once_with(state, force_refresh=False) - configure.assert_called_once_with("copilot", state, parent_schema="main.models") + configure.assert_called_once_with( + "copilot", state, parent_schema="main.models", with_vscode=True + ) install_ai_tools.assert_called_once_with(["copilot"], state, force_refresh=True) @pytest.mark.parametrize("apply_managed", [True, False]) diff --git a/vscode-extension/README.md b/vscode-extension/README.md index 8abee96ef..2534ad955 100644 --- a/vscode-extension/README.md +++ b/vscode-extension/README.md @@ -4,10 +4,10 @@ Adds Databricks AI Gateway models to the VS Code chat model picker, for Copilot mode. The extension calls the workspace's AI Gateway directly: Anthropic Messages for Claude, Responses for GPT-5 and later, and Chat Completions for older GPT models. -`ug configure --agents vscode` packs the extension with a `ug-config.json` that holds the workspace URL, +`ug configure --agents copilot` packs the extension with a `ug-config.json` that holds the workspace URL, the models (id, picker name, family, the wire to call it on - `anthropic`, `responses` or `completions` - and token limits) and a command that prints a Databricks access token (`ug auth-token --host ...`), then installs -it. To change the models or the workspace, run `ug configure --agents vscode` again and restart VS Code. +it. To change the models or the workspace, run `ug configure --agents copilot` again and restart VS Code. The models are the workspace's own (`system.ai`) by default. With `--provider ` they are a Model Provider Service's: the config then names the service, every request carries the diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 4efd792e9..3d01dfbf8 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -99,7 +99,7 @@ export class DatabricksModels implements vscode.LanguageModelChatProvider async provideLanguageModelChatInformation(): Promise { const { workspace, models, authCommand } = this.settings(); if (!workspace || !authCommand || models.length === 0) { - this.log("No Databricks models: run `ug configure --agents vscode` to set them up."); + this.log("No Databricks models: run `ug configure --agents copilot` to set them up."); return []; } return models.map(({ id, name, family, api, maxInputTokens, maxOutputTokens }) => ({ diff --git a/vscode-extension/src/gateway.ts b/vscode-extension/src/gateway.ts index d07da86bd..549a4170c 100644 --- a/vscode-extension/src/gateway.ts +++ b/vscode-extension/src/gateway.ts @@ -25,7 +25,7 @@ export async function* streamEvents( } if (response.status === 401) { await response.body?.cancel(); - throw new Error("Databricks rejected the sign-in. Run `ug configure --agents vscode` to sign in again, then retry."); + throw new Error("Databricks rejected the sign-in. Run `ug configure --agents copilot` to sign in again, then retry."); } if (!response.ok || !response.body) { const detail = (await response.text()).slice(0, 300); diff --git a/vscode-extension/test/extension.test.ts b/vscode-extension/test/extension.test.ts index af3d44225..ea35f33fa 100644 --- a/vscode-extension/test/extension.test.ts +++ b/vscode-extension/test/extension.test.ts @@ -59,7 +59,7 @@ describe("streamEvents", () => { requests = stubFetch([401, ""]); await rejectsWith( collect(streamEvents("https://ws", "/r", {}, new Tokens(tokenCommand(["a", "b"]).command), {}, new AbortController().signal)), - "Run `ug configure --agents vscode` to sign in again", + "Run `ug configure --agents copilot` to sign in again", ); assert.equal(requests.length, 2); }); @@ -170,7 +170,7 @@ describe("DatabricksModels", () => { ], ); assert.deepEqual(await new DatabricksModels(() => ({ ...settings(""), models: [] }), (line) => logs.push(line)).provideLanguageModelChatInformation(), []); - assert.deepEqual(logs, ["No Databricks models: run `ug configure --agents vscode` to set them up."]); + assert.deepEqual(logs, ["No Databricks models: run `ug configure --agents copilot` to set them up."]); }); const answers: [number, string][] = [ From aeffabbf597fce533e7cc64c77df73c5c58e1a46 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Mon, 2 Nov 2026 13:59:15 +0000 Subject: [PATCH 14/18] copilot: refuse a Copilot MCP config ug can't parse instead of editing it through the CLI Nothing writes comments into ~/.copilot/mcp-config.json: ug, the Copilot CLI and VS Code all save plain JSON, and VS Code's local agent ignores a commented file. So the fallback that applied changes through `copilot mcp add/remove` and kept a copy of the original covered a rare case at a high cost. ug now leaves such a file untouched and asks to remove the comments, as VS Code does, whether or not the Copilot CLI is installed. An emptied config is written as `{"mcpServers": {}}` like the other agents' configs. Co-authored-by: Isaac --- src/ucode/agents/copilot.py | 76 +++----------------------- tests/conftest.py | 3 -- tests/test_agent_copilot.py | 104 +----------------------------------- tests/test_mcp.py | 1 - 4 files changed, 9 insertions(+), 175 deletions(-) diff --git a/src/ucode/agents/copilot.py b/src/ucode/agents/copilot.py index faf48474b..5e2147d56 100644 --- a/src/ucode/agents/copilot.py +++ b/src/ucode/agents/copilot.py @@ -29,8 +29,6 @@ import os import re import secrets -import shutil -import subprocess import threading import time from collections.abc import Callable, Iterable, Iterator @@ -83,7 +81,7 @@ from ucode.smart_routing.routing import unwrap_anthropic_gateway_model from ucode.state import is_tool_managed, mark_tool_managed, save_state from ucode.telemetry import agent_version, ucode_user_agent, ug_version -from ucode.ui import print_note, print_warning +from ucode.ui import print_warning from .args import LaunchOptions, explicit_model_arg_value from .request_tags import request_tags_header_value @@ -95,7 +93,6 @@ # Older ug versions kept their servers in a side file loaded with `--additional-mcp-config`. LEGACY_MCP_CONFIG_PATH = COPILOT_CONFIG_DIR / "ucode-mcp-config.json" LEGACY_MCP_BACKUP_PATH = APP_DIR / "copilot-ucode-mcp-config.backup.json" -MCP_CONFIG_ORIGINAL_PATH = APP_DIR / "copilot-mcp-config.original.json" COPILOT_PROXY_LOG_PATH = APP_DIR / "logs" / "copilot-proxy.log" _REGISTRY_PREFIX = "ug-providers-" _REGISTRY_LOCK_NAME = "ug-providers.lock" @@ -845,67 +842,16 @@ def _read_legacy_mcp_servers() -> dict[str, dict]: return {name: entry for name, entry in servers.items() if isinstance(entry, dict)} -def _remove_mcp_server_via_cli(name: str) -> bool: - try: - result = subprocess_cross_os.run( - [SPEC["binary"], "mcp", "remove", name], - check=False, - capture_output=True, - text=True, - timeout=30, - ) - except subprocess.TimeoutExpired as exc: - raise RuntimeError(f"Timed out removing MCP server '{name}' via copilot CLI.") from exc - if "not found" in f"{result.stderr or ''}\n{result.stdout or ''}".lower(): - return False - if result.returncode != 0: - raise RuntimeError(f"Failed to remove MCP server '{name}' via copilot CLI.") - return True - - -def _add_mcp_server_via_cli(name: str, entry: dict) -> None: - # The CLI refuses an existing name, so a replace removes it first. - _remove_mcp_server_via_cli(name) - argv = [entry["command"], *entry.get("args", [])] - try: - subprocess_cross_os.run( - [SPEC["binary"], "mcp", "add", name, "--", *argv], - check=True, - capture_output=True, - text=True, - timeout=30, - ) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: - raise RuntimeError(f"Failed to add MCP server '{name}' via copilot CLI.") from exc - - def _apply_mcp_diff(add: dict[str, dict], remove: set[str]) -> set[str]: path = copilot_mcp_config_path() config = _read_mcp_config_for_rewrite(path) + if config is None: + raise RuntimeError( + f"{path} isn't plain JSON (it may have comments). Remove the comments, then try " + "again; VS Code's local agent also skips the file while it has them." + ) legacy = _read_legacy_mcp_servers() upserts = {name: entry for name, entry in legacy.items() if name not in remove} | add - if config is None: - if not shutil.which(SPEC["binary"]): - raise RuntimeError( - f"{path} isn't plain JSON (it may have comments), and ug edits such a file only " - "through the Copilot CLI. Remove the comments, then try again; VS Code's local " - "agent also skips the file while it has them." - ) - # Defer to the copilot CLI, which reads the file the way Copilot does, for exactly the - # changed entries. It saves the file as plain JSON, dropping its comments, so the - # original is kept once for the developer to recover them. - first_cli_edit = (upserts or remove) and not MCP_CONFIG_ORIGINAL_PATH.exists() - if first_cli_edit and backup_existing_file(path, MCP_CONFIG_ORIGINAL_PATH): - print_note( - f"The Copilot CLI saves {path} without its comments; the original is at " - f"{MCP_CONFIG_ORIGINAL_PATH}." - ) - removed = {name for name in remove if _remove_mcp_server_via_cli(name) or name in legacy} - for name, entry in upserts.items(): - _add_mcp_server_via_cli(name, entry) - remove_legacy_mcp_config() - return removed - servers = config.get("mcpServers") if not isinstance(servers, dict): servers = {} @@ -915,15 +861,7 @@ def _apply_mcp_diff(add: dict[str, dict], remove: set[str]) -> set[str]: servers.pop(name, None) servers.update(upserts) config["mcpServers"] = servers - if config == {"mcpServers": {}}: - # Copilot reads a missing file the same way, so don't leave an empty one behind. - if not is_dry_run(): - try: - path.unlink(missing_ok=True) - except OSError as exc: - raise RuntimeError(f"Failed to remove {path}") from exc - else: - write_json_file(path, config) + write_json_file(path, config) remove_legacy_mcp_config() return removed diff --git a/tests/conftest.py b/tests/conftest.py index 784a9e059..78e9e91e4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -139,9 +139,6 @@ def install_test_policy(path, desired_text, *, interactive=False, allow_sudo=Tru monkeypatch.setattr( copilot_mod, "LEGACY_MCP_BACKUP_PATH", state_dir / "copilot-ucode-mcp-config.backup.json" ) - monkeypatch.setattr( - copilot_mod, "MCP_CONFIG_ORIGINAL_PATH", state_dir / "copilot-mcp-config.original.json" - ) monkeypatch.delenv("COPILOT_HOME", raising=False) def reject_privileged_write(path, _desired_text): diff --git a/tests/test_agent_copilot.py b/tests/test_agent_copilot.py index a038be0a9..134620e47 100644 --- a/tests/test_agent_copilot.py +++ b/tests/test_agent_copilot.py @@ -5,7 +5,6 @@ import contextlib import json import os -import subprocess from pathlib import Path import httpx @@ -913,116 +912,17 @@ def test_honors_copilot_home(self, tmp_path, monkeypatch): written = json.loads((tmp_path / "copilot-home" / "mcp-config.json").read_text()) assert written["mcpServers"] == {"svc": self.ENTRY} - @staticmethod - def _stub_cli(monkeypatch, *, fail_add=False): - calls: list[list[str]] = [] - - def run(argv, **kwargs): - calls.append(argv) - if argv[2] == "add" and fail_add: - raise subprocess.CalledProcessError(1, argv) - stderr = f'Server "{argv[3]}" not found.' if argv[2] == "remove" else "" - return subprocess.CompletedProcess(argv, 1 if stderr else 0, "", stderr) - - monkeypatch.setattr(copilot.subprocess_cross_os, "run", run) - monkeypatch.setattr(copilot.shutil, "which", lambda binary: f"/usr/bin/{binary}") - return calls - - def test_defers_to_the_copilot_cli_for_a_config_it_cannot_parse(self, monkeypatch, capsys): - calls = self._stub_cli(monkeypatch) - path = copilot.copilot_mcp_config_path() - path.parent.mkdir(parents=True, exist_ok=True) - jsonc = '{\n // mine\n "mcpServers": {"mine": {"x": 1},},\n}\n' - path.write_text(jsonc, encoding="utf-8") - copilot.LEGACY_MCP_CONFIG_PATH.write_text(json.dumps({"mcpServers": {"old": self.ENTRY}})) - - removed = copilot.write_user_mcp_servers({"svc": self.ENTRY}, {"gone"}) - - assert removed == set() - assert calls == [ - ["copilot", "mcp", "remove", "gone"], - ["copilot", "mcp", "remove", "old"], - ["copilot", "mcp", "add", "old", "--", "ug", "mcp-proxy"], - ["copilot", "mcp", "remove", "svc"], - ["copilot", "mcp", "add", "svc", "--", "ug", "mcp-proxy"], - ] - # The real CLI saves the file as plain JSON, so the original is kept for its comments. - assert copilot.MCP_CONFIG_ORIGINAL_PATH.read_text(encoding="utf-8") == jsonc - assert "without its comments" in " ".join(capsys.readouterr().out.split()) - assert not copilot.LEGACY_MCP_CONFIG_PATH.exists() - - def test_cli_edits_keep_only_the_first_original(self, monkeypatch, capsys): - self._stub_cli(monkeypatch) - path = copilot.copilot_mcp_config_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("{\n // first\n}\n", encoding="utf-8") - copilot.write_user_mcp_servers({"svc": self.ENTRY}, set()) - capsys.readouterr() - path.write_text("{\n // second\n}\n", encoding="utf-8") - - copilot.write_user_mcp_servers({}, {"svc"}) - - assert copilot.MCP_CONFIG_ORIGINAL_PATH.read_text(encoding="utf-8") == "{\n // first\n}\n" - assert "without its comments" not in capsys.readouterr().out - - def test_a_cli_pass_with_nothing_to_change_keeps_no_original(self, monkeypatch): - calls = self._stub_cli(monkeypatch) - path = copilot.copilot_mcp_config_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("{\n // mine\n}\n", encoding="utf-8") - copilot.LEGACY_MCP_BACKUP_PATH.parent.mkdir(parents=True, exist_ok=True) - copilot.LEGACY_MCP_BACKUP_PATH.write_text("{}", encoding="utf-8") - - copilot.migrate_legacy_mcp_config() - - assert calls == [] - assert not copilot.MCP_CONFIG_ORIGINAL_PATH.exists() - assert not copilot.LEGACY_MCP_BACKUP_PATH.exists() - - def test_commented_config_without_the_copilot_cli_is_left_alone(self, monkeypatch): - # Without the Copilot CLI, ug has nothing to edit a file it can't parse with. - calls = self._stub_cli(monkeypatch) - monkeypatch.setattr(copilot.shutil, "which", lambda binary: None) + def test_a_config_ug_cannot_parse_is_left_alone(self): path = copilot.copilot_mcp_config_path() path.parent.mkdir(parents=True, exist_ok=True) jsonc = '{\n // mine\n "mcpServers": {"mine": {"x": 1}},\n}\n' path.write_text(jsonc, encoding="utf-8") + copilot.LEGACY_MCP_CONFIG_PATH.write_text(json.dumps({"mcpServers": {"old": self.ENTRY}})) with pytest.raises(RuntimeError, match="Remove the comments"): copilot.write_user_mcp_servers({"svc": self.ENTRY}, set()) - assert calls == [] assert path.read_text(encoding="utf-8") == jsonc - - def test_removing_the_last_server_deletes_a_config_with_nothing_else(self): - path = copilot.copilot_mcp_config_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps({"mcpServers": {"svc": self.ENTRY}}), encoding="utf-8") - - assert copilot.write_user_mcp_servers({}, {"svc"}) == {"svc"} - - assert not path.exists() - - def test_removing_the_last_server_keeps_a_config_with_other_keys(self): - path = copilot.copilot_mcp_config_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps({"mcpServers": {"svc": self.ENTRY}, "x": 1}), encoding="utf-8") - - copilot.write_user_mcp_servers({}, {"svc"}) - - assert json.loads(path.read_text(encoding="utf-8")) == {"mcpServers": {}, "x": 1} - - def test_failed_cli_add_keeps_the_legacy_side_file(self, monkeypatch): - self._stub_cli(monkeypatch, fail_add=True) - path = copilot.copilot_mcp_config_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("{not json", encoding="utf-8") - copilot.LEGACY_MCP_CONFIG_PATH.write_text(json.dumps({"mcpServers": {"old": self.ENTRY}})) - - with pytest.raises(RuntimeError, match="Failed to add MCP server 'old'"): - copilot.migrate_legacy_mcp_config() - - assert path.read_text(encoding="utf-8") == "{not json" assert copilot.LEGACY_MCP_CONFIG_PATH.exists() def test_failed_write_keeps_the_legacy_side_file(self, monkeypatch): diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 6c05973bc..aa16f3ec2 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -3293,7 +3293,6 @@ def test_removes_copilot_servers_from_its_user_config_and_the_legacy_side_file(s def test_commented_copilot_config_without_its_cli_does_not_stop_the_revert( self, monkeypatch, capsys ): - monkeypatch.setattr(mcp.copilot.shutil, "which", lambda binary: None) user_config = mcp.copilot.copilot_mcp_config_path() user_config.parent.mkdir(parents=True, exist_ok=True) jsonc = '{\n // mine\n "mcpServers": {"svc": {"type": "local"}}\n}\n' From 98dce06d6d6cb784ee82fd44ded3a00b10a17eae Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Mon, 2 Nov 2026 14:54:07 +0000 Subject: [PATCH 15/18] copilot: check --provider / --model-location before saving them as Copilot's model source A mistyped provider service or a schema with no usable models was saved as Copilot's model source even though setup failed or only warned, so it replaced a working source and every later `ug copilot` launch failed on it. `ug configure` now resolves the source the way a launch does and fails with that error, leaving the saved source as it was. Co-authored-by: Isaac --- src/ucode/cli.py | 8 +++++++- tests/test_agent_vscode.py | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index ca6784f37..480900f4c 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -1371,8 +1371,14 @@ def _configure_workspace_command( for tool_name in picked: state = _maybe_select_provider_service(tool_name, state) if copilot_source is not None: - # Saved, so `ug copilot` launches on the same models VS Code gets. provider_service, model_location = copilot_source + # Resolve the source the way `ug copilot` will, so a bad one fails here instead of being + # saved over a working one. + if provider_service: + copilot_agent.provider_service_catalog(state, provider_service, None) + else: + copilot_agent.location_catalog(state, str(model_location)) + # Saved, so `ug copilot` launches on the same models VS Code gets. state = set_provider_service(state, AGENT_COPILOT, provider_service) state = set_model_location(state, AGENT_COPILOT, model_location) diff --git a/tests/test_agent_vscode.py b/tests/test_agent_vscode.py index e72529176..c9e514908 100644 --- a/tests/test_agent_vscode.py +++ b/tests/test_agent_vscode.py @@ -560,6 +560,7 @@ def _stub_workspace_setup(monkeypatch, state: dict, managed: dict | None) -> lis def test_configure_saves_copilots_model_source_for_its_launches(monkeypatch): state = {**STATE, "provider_services": {"copilot": "old.default.mps"}} seen = _stub_workspace_setup(monkeypatch, state, None) + monkeypatch.setattr(copilot, "location_catalog", lambda state, schema: ["a model"]) cli_mod.configure_workspace_command( selected_tools=["copilot"], workspaces=[(WS, None)], copilot_source=(None, "main.models") @@ -569,6 +570,28 @@ def test_configure_saves_copilots_model_source_for_its_launches(monkeypatch): assert seen[0]["model_locations"] == {"copilot": "main.models"} +@pytest.mark.parametrize( + "source", [("main.default.typo", None), (None, "main.empty")], ids=["provider", "location"] +) +def test_configure_keeps_copilots_source_when_the_new_one_fails(monkeypatch, source): + def unusable(*args, **kwargs): + raise RuntimeError("not usable") + + state = {**STATE, "provider_services": {"copilot": "old.default.mps"}} + seen = _stub_workspace_setup(monkeypatch, state, None) + monkeypatch.setattr(copilot, "provider_service_catalog", unusable) + monkeypatch.setattr(copilot, "location_catalog", unusable) + + with pytest.raises(RuntimeError, match="not usable"): + cli_mod.configure_workspace_command( + selected_tools=["copilot"], workspaces=[(WS, None)], copilot_source=source + ) + + assert seen == [] + assert state["provider_services"] == {"copilot": "old.default.mps"} + assert "model_locations" not in state + + def test_configure_refuses_a_copilot_model_source_on_a_managed_workspace(monkeypatch): seen = _stub_workspace_setup( monkeypatch, dict(STATE), {"enabled_agents": {"copilot": {"model_config": {}}}} From 3bda21499c4e5b49882e0060f396e6a627ac3343 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Tue, 3 Nov 2026 13:17:14 +0000 Subject: [PATCH 16/18] copilot: ask the gateway for coding-agent mode, so Copilot's tool search beta isn't rejected Copilot CLI 1.0.100+ turns on tool search once it has about 20 MCP tools and then sends anthropic-beta: advanced-tool-use-2025-11-20. Claude served through Bedrock rejects that value with a 400 unless the request carries x-databricks-use-coding-agent-mode, which makes the gateway map or drop betas its backend lacks. ug already sends the header for Claude Code and Claude Desktop; the Copilot session proxy now adds it too. Co-authored-by: Isaac --- src/ucode/agents/copilot.py | 6 +++++- tests/test_agent_copilot.py | 13 ++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/ucode/agents/copilot.py b/src/ucode/agents/copilot.py index 5e2147d56..e67792638 100644 --- a/src/ucode/agents/copilot.py +++ b/src/ucode/agents/copilot.py @@ -933,7 +933,11 @@ def _token_provider(state: dict) -> Callable[[bool], str]: def proxy_headers(state: dict) -> dict[str, str]: """Headers the proxy adds to every gateway request; admin-managed headers win by name.""" headers = { - "User-Agent": ucode_user_agent(ug_version(), f"copilot/{agent_version(SPEC['binary'])}") + "User-Agent": ucode_user_agent(ug_version(), f"copilot/{agent_version(SPEC['binary'])}"), + # Makes the gateway map or drop the Anthropic betas its serving backend lacks. Without it, + # Copilot's tool search beta (`advanced-tool-use-2025-11-20`, sent once it has enough MCP + # tools) gets a 400 from Claude served through Bedrock. + "x-databricks-use-coding-agent-mode": "true", } request_tags = request_tags_header_value() if request_tags: diff --git a/tests/test_agent_copilot.py b/tests/test_agent_copilot.py index 134620e47..fd2a830d2 100644 --- a/tests/test_agent_copilot.py +++ b/tests/test_agent_copilot.py @@ -1082,6 +1082,13 @@ def test_identifies_ucode_and_copilot(self, monkeypatch): assert headers["User-Agent"] == f"ucode/0.9.0 copilot/1.0.91 {client_platform()}" + def test_asks_the_gateway_for_coding_agent_mode(self, monkeypatch): + monkeypatch.setattr(copilot, "agent_version", lambda binary: "1") + + headers = copilot.proxy_headers({}) + + assert headers["x-databricks-use-coding-agent-mode"] == "true" + def test_adds_request_tags(self, monkeypatch): monkeypatch.setattr(copilot, "agent_version", lambda binary: "1") monkeypatch.setenv("AI_GATEWAY_REQUEST_TAGS", '{"team":"ug"}') @@ -1104,7 +1111,11 @@ def test_managed_headers_win_by_name_but_never_replace_authorization(self, monke } ) - assert headers == {"user-agent": "admin-agent", "X-Team": "data"} + assert headers == { + "x-databricks-use-coding-agent-mode": "true", + "user-agent": "admin-agent", + "X-Team": "data", + } class TestTokenProvider: From 6334bc9a84d53441a2774bd1960ecad0b64bd442 Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Tue, 3 Nov 2026 15:59:51 +0000 Subject: [PATCH 17/18] configure: name Copilot "GitHub Copilot" in the Configuration Complete box ug configure now sets up VS Code along with the Copilot CLI, so the summary names the agent, not the CLI. ug copilot's first-run setup still says GitHub Copilot CLI, since it configures only the CLI. Co-authored-by: Isaac --- src/ucode/cli.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 480900f4c..f183b303e 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -1030,12 +1030,11 @@ def _setup_single_agent( # Only the Claude and Codex branch above reads the managed config fresh. managed_refreshed = apply_managed and tool in ("claude", "codex") install_databricks_ai_tools_for_agents([tool], state, force_refresh=not managed_refreshed) - spec = TOOL_SPECS[tool] provider_summary = "Databricks" if parent_schema else _provider_summary(tool, state) console.print( Panel( f"[bold]Workspace:[/bold] [cyan]{state['workspace']}[/cyan]\n" - f"[bold]{spec['display']}:[/bold] [green]configured[/green] " + f"[bold]{_summary_label(tool)}:[/bold] [green]configured[/green] " f"[dim](Provider: {provider_summary})[/dim]", title="Configuration Complete", style="green", @@ -1413,16 +1412,15 @@ def _configure_workspace_command( _sync_claude_cowork_plugin(state) summary_lines = [f"[bold]Workspace:[/bold] [cyan]{state['workspace']}[/cyan]"] for tool_name in picked: - spec = TOOL_SPECS[tool_name] + label = _summary_label(tool_name) if tool_name in configured_set: summary_lines.append( - f"[bold]{spec['display']}:[/bold] [green]configured[/green] " + f"[bold]{label}:[/bold] [green]configured[/green] " f"[dim](Provider: {_provider_summary(tool_name, state)})[/dim]" ) else: summary_lines.append( - f"[bold]{spec['display']}:[/bold] [yellow]not configured " - "(see warnings above)[/yellow]" + f"[bold]{label}:[/bold] [yellow]not configured (see warnings above)[/yellow]" ) console.print( Panel( @@ -1445,6 +1443,14 @@ def _agent_label(tool: str) -> str: return str(TOOL_SPECS[tool]["display"]) +def _summary_label(tool: str) -> str: + """An agent's name in ``ug configure``'s Configuration Complete box, which also covers VS Code + for copilot.""" + if tool == AGENT_COPILOT: + return "GitHub Copilot" + return str(TOOL_SPECS[tool]["display"]) + + def _print_status_panel(title: str, rows: list[tuple[str, str]]) -> None: """Render a compact two-column status card with values that wrap safely.""" table = Table.grid(padding=(0, 2)) From e21c98160b9d392a21f7a5d0a02e4875a868763e Mon Sep 17 00:00:00 2001 From: Xi Lyu Date: Tue, 3 Nov 2026 16:06:10 +0000 Subject: [PATCH 18/18] vscode: select the workspace on hosts that serve several, and report a sign-in page clearly On a host that serves several workspaces, a gateway request without X-Databricks-Org-Id / X-Databricks-Workspace-Id is redirected to the sign-in page. fetch follows the redirect and gets a 200 HTML page with no stream events, so every VS Code model failed with "The response ended before the model finished". ug copilot worked because its session proxy adds the selector. ug configure now writes the workspace's selector headers into the extension's config, and the extension sends them on every request. A 200 text/html answer now fails with a message that says the gateway answered with a web page. Rebuilt the bundle. Co-authored-by: Isaac --- src/ucode/agents/vscode.py | 4 +++ src/ucode/vscode/extension/out/extension.js | 6 ++-- tests/test_agent_vscode.py | 8 +++++ vscode-extension/src/extension.ts | 12 +++++-- vscode-extension/src/gateway.ts | 5 +++ vscode-extension/test/extension.test.ts | 38 +++++++++++++++++---- 6 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/ucode/agents/vscode.py b/src/ucode/agents/vscode.py index efdf7a517..66cbe553a 100644 --- a/src/ucode/agents/vscode.py +++ b/src/ucode/agents/vscode.py @@ -38,6 +38,7 @@ from ucode.os_compatibility.system_cross_os import windows_program_files_dir from ucode.string_utils import model_picker_label from ucode.ui import console, print_heading, print_kv, print_note, print_warning +from ucode.workspace import selector_headers EXTENSION_DIR = Path(__file__).resolve().parent.parent / "vscode" / "extension" EXTENSION_ID = "databricks.unity-gateway" @@ -270,6 +271,9 @@ def configure(state: dict, *, provider: str | None = None, location: str | None } if provider: config["provider"] = provider + # A host that serves several workspaces answers a request without them with its sign-in page. + if selector := selector_headers(state.get("workspace_id")): + config["headers"] = selector source = f" from {provider or location}" if provider or location else "" try: print_kv( diff --git a/src/ucode/vscode/extension/out/extension.js b/src/ucode/vscode/extension/out/extension.js index b74715d6e..59b788f9b 100644 --- a/src/ucode/vscode/extension/out/extension.js +++ b/src/ucode/vscode/extension/out/extension.js @@ -1,4 +1,4 @@ -"use strict";var se=Object.create;var b=Object.defineProperty;var ae=Object.getOwnPropertyDescriptor;var re=Object.getOwnPropertyNames;var ie=Object.getPrototypeOf,ce=Object.prototype.hasOwnProperty;var le=(e,n)=>{for(var o in n)b(e,o,{get:n[o],enumerable:!0})},j=(e,n,o,s)=>{if(n&&typeof n=="object"||typeof n=="function")for(let t of re(n))!ce.call(e,t)&&t!==o&&b(e,t,{get:()=>n[t],enumerable:!(s=ae(n,t))||s.enumerable});return e};var L=(e,n,o)=>(o=e!=null?se(ie(e)):{},j(n||!e||!e.__esModule?b(o,"default",{value:e,enumerable:!0}):o,e)),de=e=>j(b({},"__esModule",{value:!0}),e);var Te={};le(Te,{CONFIG_FILE:()=>Q,DatabricksModels:()=>k,PROVIDER_HEADER:()=>X,activate:()=>Ce,readConfig:()=>Z,userAgent:()=>ee});module.exports=de(Te);var z=require("node:fs"),V=require("node:path"),h=L(require("vscode"));var M="0.1.0";var I=require("node:child_process"),pe=5*6e4,ue=60*6e4,me=e=>new Promise((n,o)=>{(0,I.exec)(e,{timeout:6e4,windowsHide:!0},(s,t,r)=>{s?o(new Error(`Couldn't get a Databricks token: \`${e}\` failed: ${String(r).trim().slice(0,500)||s.message}`)):n(String(t).trim())})}),fe=e=>{try{let{exp:n}=JSON.parse(Buffer.from(e.split(".")[1]??"","base64url").toString());if(typeof n=="number")return n*1e3}catch{}return Date.now()+ue},x=class{constructor(n,o=()=>{}){this.command=n;this.onFetch=o}command;onFetch;cached;pending;get(){return this.cached&&this.cached.expiresAt-pe>Date.now()?Promise.resolve(this.cached.value):this.fetch(this.command)}async renew(n){return await this.pending?.catch(()=>{}),this.cached&&this.cached.value!==n?this.cached.value:(this.cached=void 0,this.fetch(`${this.command} --force-refresh`))}fetch(n){return this.pending??=me(n).then(o=>{if(!o)throw new Error(`Couldn't get a Databricks token: \`${n}\` printed nothing.`);return this.cached={value:o,expiresAt:fe(o)},this.onFetch(this.cached.expiresAt),o}).finally(()=>{this.pending=void 0}),this.pending}};var l=L(require("vscode")),C=3,he=1e3,v=e=>e instanceof l.LanguageModelTextPart?e.value:e instanceof l.LanguageModelDataPart&&!e.mimeType.startsWith("image/")?new TextDecoder().decode(e.data):e instanceof l.LanguageModelPromptTsxPart?JSON.stringify(e.value):"",D=e=>e instanceof l.LanguageModelDataPart&&e.mimeType.startsWith("image/")?{mimeType:e.mimeType,data:Buffer.from(e.data).toString("base64")}:void 0,$=e=>{let n=D(e);return n&&`data:${n.mimeType};base64,${n.data}`},T=e=>e.inputSchema??{type:"object",properties:{}},R=(e,n,o)=>{let s;try{s=o.trim()?JSON.parse(o):{}}catch{s=void 0}if(typeof s!="object"||s===null||Array.isArray(s))throw new Error(`The model called ${n} with arguments that aren't a JSON object: ${o}`);return new l.LanguageModelToolCallPart(e,n,s)},S=e=>e==="max_output_tokens"||e==="length"||e==="max_tokens"?new Error("The model stopped at its output token limit before finishing the response."):e==="content_filter"||e==="refusal"?new Error("The model's response was stopped by a content filter."):new Error(`The model stopped before finishing the response (${e??"no reason given"}).`),P=()=>new Error("The response ended before the model finished. Try again."),q=(e,n)=>e.tools.length?{tools:e.tools.map(n),...e.toolRequired?{tool_choice:"required"}:{}}:{},G=e=>{if(typeof e=="string")return Math.ceil(e.length/4);let n=0,o=0;for(let s of e.content){let t=s instanceof l.LanguageModelToolResultPart?s.content:[s];for(let r of t)n+=r instanceof l.LanguageModelToolCallPart?r.name.length+JSON.stringify(r.input).length:v(r).length,o+=$(r)===void 0?0:1}return Math.ceil(n/4)+o*he},A=e=>e.map($).filter(n=>n!==void 0),E=e=>({results:e.filter(n=>n instanceof l.LanguageModelToolResultPart),calls:e.filter(n=>n instanceof l.LanguageModelToolCallPart),text:e.map(v).join(""),images:A(e)});function J(e){let n=e.messages.flatMap(o=>{let{results:s,calls:t,text:r,images:d}=E(o.content);if(Number(o.role)===C)return[{role:"system",content:r}];if(o.role===l.LanguageModelChatMessageRole.Assistant){let a=t.map(i=>({id:i.callId,type:"function",function:{name:i.name,arguments:JSON.stringify(i.input)}}));return r||a.length?[{role:"assistant",content:r||null,...a.length?{tool_calls:a}:{}}]:[]}let c=[...r?[{type:"text",text:r}]:[],...[...s.flatMap(a=>A(a.content)),...d].map(a=>({type:"image_url",image_url:{url:a}}))];return[...s.map(a=>({role:"tool",tool_call_id:a.callId,content:a.content.map(v).join("")})),...c.length?[{role:"user",content:c}]:[]]});return{model:e.model,messages:n,stream:!0,...q(e,o=>({type:"function",function:{name:o.name,description:o.description,parameters:T(o)}}))}}var ye=e=>typeof e=="string"?e:(e??[]).filter(n=>n.type==="text").map(n=>n.text??"").join(""),we=new Set(["stop","tool_calls","function_call"]);async function*F(e){let n=[],o;for await(let s of e){let{error:t}=s;if(t)throw new Error(typeof t=="string"?t:t.message??JSON.stringify(t));for(let r of s.choices??[]){let d=ye(r.delta?.content);d&&(yield new l.LanguageModelTextPart(d));for(let c of r.delta?.tool_calls??[]){let a=n[c.index??0]??={id:"",name:"",args:""};a.id=c.id||a.id,a.name=c.function?.name||a.name,a.args+=c.function?.arguments??""}if(r.finish_reason&&(o=r.finish_reason,!we.has(o)))throw S(o)}}if(!o)throw P();for(let s of n.filter(Boolean))yield R(s.id,s.name,s.args)}var ve=e=>{let n=e.map(v).join(""),o=A(e);return o.length?[...n?[{type:"input_text",text:n}]:[],...o.map(s=>({type:"input_image",image_url:s,detail:"auto"}))]:n};function H(e){let n=[],o=e.messages.flatMap(s=>{let{results:t,calls:r,text:d,images:c}=E(s.content);if(Number(s.role)===C)return n.push(d),[];if(s.role===l.LanguageModelChatMessageRole.Assistant)return[...d?[{role:"assistant",content:d}]:[],...r.map(i=>({type:"function_call",call_id:i.callId,name:i.name,arguments:JSON.stringify(i.input)}))];let a=[...d?[{type:"input_text",text:d}]:[],...c.map(i=>({type:"input_image",image_url:i,detail:"auto"}))];return[...t.map(i=>({type:"function_call_output",call_id:i.callId,output:ve(i.content)})),...a.length?[{role:"user",content:a}]:[]]});return{model:e.model,...n.length?{instructions:n.join(` +"use strict";var re=Object.create;var v=Object.defineProperty;var ae=Object.getOwnPropertyDescriptor;var ie=Object.getOwnPropertyNames;var ce=Object.getPrototypeOf,le=Object.prototype.hasOwnProperty;var de=(e,n)=>{for(var o in n)v(e,o,{get:n[o],enumerable:!0})},O=(e,n,o,s)=>{if(n&&typeof n=="object"||typeof n=="function")for(let t of ie(n))!le.call(e,t)&&t!==o&&v(e,t,{get:()=>n[t],enumerable:!(s=ae(n,t))||s.enumerable});return e};var I=(e,n,o)=>(o=e!=null?re(ce(e)):{},O(n||!e||!e.__esModule?v(o,"default",{value:e,enumerable:!0}):o,e)),ge=e=>O(v({},"__esModule",{value:!0}),e);var Se={};de(Se,{CONFIG_FILE:()=>Z,DatabricksModels:()=>_,PROVIDER_HEADER:()=>Q,activate:()=>Re,readConfig:()=>ee,userAgent:()=>te});module.exports=ge(Se);var z=require("node:fs"),X=require("node:path"),h=I(require("vscode"));var M="0.1.0";var L=require("node:child_process"),ue=5*6e4,me=60*6e4,fe=e=>new Promise((n,o)=>{(0,L.exec)(e,{timeout:6e4,windowsHide:!0},(s,t,a)=>{s?o(new Error(`Couldn't get a Databricks token: \`${e}\` failed: ${String(a).trim().slice(0,500)||s.message}`)):n(String(t).trim())})}),he=e=>{try{let{exp:n}=JSON.parse(Buffer.from(e.split(".")[1]??"","base64url").toString());if(typeof n=="number")return n*1e3}catch{}return Date.now()+me},b=class{constructor(n,o=()=>{}){this.command=n;this.onFetch=o}command;onFetch;cached;pending;get(){return this.cached&&this.cached.expiresAt-ue>Date.now()?Promise.resolve(this.cached.value):this.fetch(this.command)}async renew(n){return await this.pending?.catch(()=>{}),this.cached&&this.cached.value!==n?this.cached.value:(this.cached=void 0,this.fetch(`${this.command} --force-refresh`))}fetch(n){return this.pending??=fe(n).then(o=>{if(!o)throw new Error(`Couldn't get a Databricks token: \`${n}\` printed nothing.`);return this.cached={value:o,expiresAt:he(o)},this.onFetch(this.cached.expiresAt),o}).finally(()=>{this.pending=void 0}),this.pending}};var l=I(require("vscode")),C=3,ye=1e3,w=e=>e instanceof l.LanguageModelTextPart?e.value:e instanceof l.LanguageModelDataPart&&!e.mimeType.startsWith("image/")?new TextDecoder().decode(e.data):e instanceof l.LanguageModelPromptTsxPart?JSON.stringify(e.value):"",$=e=>e instanceof l.LanguageModelDataPart&&e.mimeType.startsWith("image/")?{mimeType:e.mimeType,data:Buffer.from(e.data).toString("base64")}:void 0,q=e=>{let n=$(e);return n&&`data:${n.mimeType};base64,${n.data}`},T=e=>e.inputSchema??{type:"object",properties:{}},R=(e,n,o)=>{let s;try{s=o.trim()?JSON.parse(o):{}}catch{s=void 0}if(typeof s!="object"||s===null||Array.isArray(s))throw new Error(`The model called ${n} with arguments that aren't a JSON object: ${o}`);return new l.LanguageModelToolCallPart(e,n,s)},S=e=>e==="max_output_tokens"||e==="length"||e==="max_tokens"?new Error("The model stopped at its output token limit before finishing the response."):e==="content_filter"||e==="refusal"?new Error("The model's response was stopped by a content filter."):new Error(`The model stopped before finishing the response (${e??"no reason given"}).`),A=()=>new Error("The response ended before the model finished. Try again."),G=(e,n)=>e.tools.length?{tools:e.tools.map(n),...e.toolRequired?{tool_choice:"required"}:{}}:{},J=e=>{if(typeof e=="string")return Math.ceil(e.length/4);let n=0,o=0;for(let s of e.content){let t=s instanceof l.LanguageModelToolResultPart?s.content:[s];for(let a of t)n+=a instanceof l.LanguageModelToolCallPart?a.name.length+JSON.stringify(a.input).length:w(a).length,o+=q(a)===void 0?0:1}return Math.ceil(n/4)+o*ye},j=e=>e.map(q).filter(n=>n!==void 0),E=e=>({results:e.filter(n=>n instanceof l.LanguageModelToolResultPart),calls:e.filter(n=>n instanceof l.LanguageModelToolCallPart),text:e.map(w).join(""),images:j(e)});function F(e){let n=e.messages.flatMap(o=>{let{results:s,calls:t,text:a,images:d}=E(o.content);if(Number(o.role)===C)return[{role:"system",content:a}];if(o.role===l.LanguageModelChatMessageRole.Assistant){let r=t.map(c=>({id:c.callId,type:"function",function:{name:c.name,arguments:JSON.stringify(c.input)}}));return a||r.length?[{role:"assistant",content:a||null,...r.length?{tool_calls:r}:{}}]:[]}let i=[...a?[{type:"text",text:a}]:[],...[...s.flatMap(r=>j(r.content)),...d].map(r=>({type:"image_url",image_url:{url:r}}))];return[...s.map(r=>({role:"tool",tool_call_id:r.callId,content:r.content.map(w).join("")})),...i.length?[{role:"user",content:i}]:[]]});return{model:e.model,messages:n,stream:!0,...G(e,o=>({type:"function",function:{name:o.name,description:o.description,parameters:T(o)}}))}}var we=e=>typeof e=="string"?e:(e??[]).filter(n=>n.type==="text").map(n=>n.text??"").join(""),ve=new Set(["stop","tool_calls","function_call"]);async function*H(e){let n=[],o;for await(let s of e){let{error:t}=s;if(t)throw new Error(typeof t=="string"?t:t.message??JSON.stringify(t));for(let a of s.choices??[]){let d=we(a.delta?.content);d&&(yield new l.LanguageModelTextPart(d));for(let i of a.delta?.tool_calls??[]){let r=n[i.index??0]??={id:"",name:"",args:""};r.id=i.id||r.id,r.name=i.function?.name||r.name,r.args+=i.function?.arguments??""}if(a.finish_reason&&(o=a.finish_reason,!ve.has(o)))throw S(o)}}if(!o)throw A();for(let s of n.filter(Boolean))yield R(s.id,s.name,s.args)}var be=e=>{let n=e.map(w).join(""),o=j(e);return o.length?[...n?[{type:"input_text",text:n}]:[],...o.map(s=>({type:"input_image",image_url:s,detail:"auto"}))]:n};function U(e){let n=[],o=e.messages.flatMap(s=>{let{results:t,calls:a,text:d,images:i}=E(s.content);if(Number(s.role)===C)return n.push(d),[];if(s.role===l.LanguageModelChatMessageRole.Assistant)return[...d?[{role:"assistant",content:d}]:[],...a.map(c=>({type:"function_call",call_id:c.callId,name:c.name,arguments:JSON.stringify(c.input)}))];let r=[...d?[{type:"input_text",text:d}]:[],...i.map(c=>({type:"input_image",image_url:c,detail:"auto"}))];return[...t.map(c=>({type:"function_call_output",call_id:c.callId,output:be(c.content)})),...r.length?[{role:"user",content:r}]:[]]});return{model:e.model,...n.length?{instructions:n.join(` -`)}:{},input:o,stream:!0,store:!1,...q(e,s=>({type:"function",name:s.name,description:s.description,parameters:T(s),strict:!1}))}}async function*U(e){let n=[],o=!1;for await(let s of e){let t=s;if(t.type==="response.output_text.delta"&&t.delta)yield new l.LanguageModelTextPart(t.delta);else if(t.type==="response.output_item.done"&&t.item?.type==="function_call")n.push(R(t.item.call_id??"",t.item.name??"",t.item.arguments??""));else if(t.type==="response.completed")o=!0;else{if(t.type==="response.incomplete")throw S(t.response?.incomplete_details?.reason);if(t.type==="response.failed"||t.type==="error")throw new Error(t.response?.error?.message??t.message??"The model response failed.")}}if(!o)throw P();yield*n}var _=e=>e?[{type:"text",text:e}]:[],O=e=>e.flatMap(n=>{let o=D(n);return o?[{type:"image",source:{type:"base64",media_type:o.mimeType,data:o.data}}]:[]}),be={cache_control:{type:"ephemeral"}},N=e=>{e.length&&(e[e.length-1]={...e[e.length-1],...be})};function B(e,n){let o=[],s=[];for(let t of e.messages){let{results:r,calls:d,text:c}=E(t.content);if(Number(t.role)===C){o.push(..._(c));continue}let a=t.role===l.LanguageModelChatMessageRole.Assistant?"assistant":"user",i=a==="assistant"?[..._(c),...d.map(g=>({type:"tool_use",id:g.callId,name:g.name,input:g.input}))]:[...r.map(g=>{let p=[..._(g.content.map(v).join("")),...O(g.content)];return{type:"tool_result",tool_use_id:g.callId,...p.length?{content:p}:{}}}),..._(c),...O(t.content)],u=s[s.length-1];if(i.length)u?.role===a?u.content.push(...i):s.push({role:a,content:i});else continue}return N(o),N(s[s.length-1]?.content??[]),{model:e.model,max_tokens:n,...o.length?{system:o}:{},messages:s,stream:!0,...e.tools.length?{tools:e.tools.map(t=>({name:t.name,description:t.description,input_schema:T(t)})),...e.toolRequired?{tool_choice:{type:"any"}}:{}}:{}}}var xe=new Set(["end_turn","tool_use","stop_sequence"]);async function*W(e){let n=new Map,o=!1;for await(let s of e){let t=s;if(t.type==="content_block_start"&&t.content_block?.type==="tool_use")n.set(t.index??0,{id:t.content_block.id??"",name:t.content_block.name??"",args:""});else if(t.type==="content_block_delta"&&t.delta?.type==="text_delta"&&t.delta.text)yield new l.LanguageModelTextPart(t.delta.text);else if(t.type==="content_block_delta"&&t.delta?.type==="input_json_delta"){let r=n.get(t.index??0);r&&(r.args+=t.delta.partial_json??"")}else{if(t.type==="message_delta"&&t.delta?.stop_reason&&!xe.has(t.delta.stop_reason))throw S(t.delta.stop_reason);if(t.type==="message_stop")o=!0;else if(t.type==="error")throw new Error(t.error?.message??"The model response failed.")}}if(!o)throw P();for(let s of n.values())yield R(s.id,s.name,s.args)}async function*Y(e,n,o,s,t,r){let d=(p,f)=>fetch(`${e}${n}`,{method:"POST",headers:{...t,Authorization:`Bearer ${p}`,"Content-Type":"application/json"},body:JSON.stringify(f),signal:r}),c=await s.get(),a=await d(c,o);if(a.status===401&&(await a.body?.cancel(),c=await s.renew(c),a=await d(c,o)),a.status===401)throw await a.body?.cancel(),new Error("Databricks rejected the sign-in. Run `ug configure --agents copilot` to sign in again, then retry.");if(!a.ok||!a.body){let p=(await a.text()).slice(0,300),f=Number(/max_tokens: \d+ > (\d+)/.exec(p)?.[1]);if(!f)throw new Error(`AI Gateway returned ${a.status}: ${p}`);if(a=await d(c,{...o,max_tokens:f}),!a.ok||!a.body)throw new Error(`AI Gateway returned ${a.status}: ${(await a.text()).slice(0,300)}`)}let i=new TextDecoder,u="";for await(let p of a.body){u+=i.decode(p,{stream:!0});let f=u.split(` -`);u=f.pop()??"";for(let w of f){let y=K(w);if(y==="[DONE]")return;y&&(yield JSON.parse(y))}}let g=K(u+i.decode());g&&g!=="[DONE]"&&(yield JSON.parse(g))}var K=e=>e.startsWith("data:")?e.slice(5).trim():"";var _e=["anthropic","completions","responses"],X="Databricks-Model-Provider-Service",ke=e=>Object.fromEntries(["maxInputTokens","maxOutputTokens"].flatMap(n=>{let o=e[n];return typeof o=="number"&&Number.isInteger(o)&&o>0?[[n,o]]:[]})),Q="ug-config.json",Z=e=>{let n={};try{let t=JSON.parse(e??"{}");t&&typeof t=="object"&&!Array.isArray(t)&&(n=t)}catch{}let o=t=>n[t],s=o("models");return{workspace:String(o("workspace")??"").replace(/\/+$/,""),models:(Array.isArray(s)?s:[]).filter(t=>typeof t?.id=="string"&&t.id!==""&&_e.includes(t.api)).map(t=>({id:t.id,...typeof t.name=="string"&&t.name.trim()?{name:t.name.trim()}:{},...typeof t.family=="string"&&t.family?{family:t.family}:{},api:t.api,...ke(t)})),authCommand:String(o("authCommand")??""),provider:typeof o("provider")=="string"?String(o("provider")):""}},ee=e=>`ucode/${M} githubcopilotchat${typeof e=="string"?`/${e}`:""}`,k=class{constructor(n,o,s=()=>({})){this.settings=n;this.log=o;this.headers=s}settings;log;headers;tokens;async provideLanguageModelChatInformation(){let{workspace:n,models:o,authCommand:s}=this.settings();return!n||!s||o.length===0?(this.log("No Databricks models: run `ug configure --agents copilot` to set them up."),[]):o.map(({id:t,name:r,family:d,api:c,maxInputTokens:a,maxOutputTokens:i})=>({id:t,api:c,name:r??t,family:d??t.split(".").pop()??t,version:M,detail:"Databricks",maxInputTokens:a??128e3,maxOutputTokens:i??32e3,capabilities:{toolCalling:!0,imageInput:c!=="completions"||t.includes("claude")}}))}async provideLanguageModelChatResponse(n,o,s,t,r){let{workspace:d,authCommand:c,provider:a}=this.settings();this.tokens??=new x(c,m=>this.log(`Fetched a Databricks token (expires ${new Date(m).toISOString()})`));let i={model:n.id,messages:o,tools:s.tools??[],toolRequired:s.toolMode===h.LanguageModelChatToolMode.Required},u=new AbortController,g=r.onCancellationRequested(()=>u.abort()),p={...this.headers(),...a?{[X]:a}:{}},f=this.tokens,w=(m,ne,oe={})=>Y(d,m,ne,f,{...p,...oe},u.signal),y=a?"/ai-gateway/openai/v1":"/ai-gateway/mlflow/v1",te={anthropic:()=>W(w("/ai-gateway/anthropic/v1/messages",B(i,n.maxOutputTokens),{"anthropic-version":"2023-06-01"})),responses:()=>U(w(`${y}/responses`,H(i))),completions:()=>F(w(`${y}/chat/completions`,J(i)))};try{for await(let m of te[n.api]())t.report(m)}catch(m){if(!r.isCancellationRequested)throw this.log(`${n.id} failed: ${m instanceof Error?m.message:String(m)}`),m}finally{g.dispose()}}async provideTokenCount(n,o){return G(o)}},Me=e=>{try{return(0,z.readFileSync)(e,"utf8")}catch{return}};function Ce(e){let n=h.window.createOutputChannel("Unity Gateway",{log:!0}),o=Z(Me((0,V.join)(e.extensionPath,Q))),s=()=>({"User-Agent":ee(h.extensions.getExtension("GitHub.copilot-chat")?.packageJSON?.version)}),t=new k(()=>o,r=>n.info(r),s);e.subscriptions.push(n,h.lm.registerLanguageModelChatProvider("databricks-unity-gateway",t))}0&&(module.exports={CONFIG_FILE,DatabricksModels,PROVIDER_HEADER,activate,readConfig,userAgent}); +`)}:{},input:o,stream:!0,store:!1,...G(e,s=>({type:"function",name:s.name,description:s.description,parameters:T(s),strict:!1}))}}async function*B(e){let n=[],o=!1;for await(let s of e){let t=s;if(t.type==="response.output_text.delta"&&t.delta)yield new l.LanguageModelTextPart(t.delta);else if(t.type==="response.output_item.done"&&t.item?.type==="function_call")n.push(R(t.item.call_id??"",t.item.name??"",t.item.arguments??""));else if(t.type==="response.completed")o=!0;else{if(t.type==="response.incomplete")throw S(t.response?.incomplete_details?.reason);if(t.type==="response.failed"||t.type==="error")throw new Error(t.response?.error?.message??t.message??"The model response failed.")}}if(!o)throw A();yield*n}var x=e=>e?[{type:"text",text:e}]:[],N=e=>e.flatMap(n=>{let o=$(n);return o?[{type:"image",source:{type:"base64",media_type:o.mimeType,data:o.data}}]:[]}),xe={cache_control:{type:"ephemeral"}},D=e=>{e.length&&(e[e.length-1]={...e[e.length-1],...xe})};function W(e,n){let o=[],s=[];for(let t of e.messages){let{results:a,calls:d,text:i}=E(t.content);if(Number(t.role)===C){o.push(...x(i));continue}let r=t.role===l.LanguageModelChatMessageRole.Assistant?"assistant":"user",c=r==="assistant"?[...x(i),...d.map(g=>({type:"tool_use",id:g.callId,name:g.name,input:g.input}))]:[...a.map(g=>{let u=[...x(g.content.map(w).join("")),...N(g.content)];return{type:"tool_result",tool_use_id:g.callId,...u.length?{content:u}:{}}}),...x(i),...N(t.content)],p=s[s.length-1];if(c.length)p?.role===r?p.content.push(...c):s.push({role:r,content:c});else continue}return D(o),D(s[s.length-1]?.content??[]),{model:e.model,max_tokens:n,...o.length?{system:o}:{},messages:s,stream:!0,...e.tools.length?{tools:e.tools.map(t=>({name:t.name,description:t.description,input_schema:T(t)})),...e.toolRequired?{tool_choice:{type:"any"}}:{}}:{}}}var _e=new Set(["end_turn","tool_use","stop_sequence"]);async function*K(e){let n=new Map,o=!1;for await(let s of e){let t=s;if(t.type==="content_block_start"&&t.content_block?.type==="tool_use")n.set(t.index??0,{id:t.content_block.id??"",name:t.content_block.name??"",args:""});else if(t.type==="content_block_delta"&&t.delta?.type==="text_delta"&&t.delta.text)yield new l.LanguageModelTextPart(t.delta.text);else if(t.type==="content_block_delta"&&t.delta?.type==="input_json_delta"){let a=n.get(t.index??0);a&&(a.args+=t.delta.partial_json??"")}else{if(t.type==="message_delta"&&t.delta?.stop_reason&&!_e.has(t.delta.stop_reason))throw S(t.delta.stop_reason);if(t.type==="message_stop")o=!0;else if(t.type==="error")throw new Error(t.error?.message??"The model response failed.")}}if(!o)throw A();for(let s of n.values())yield R(s.id,s.name,s.args)}async function*Y(e,n,o,s,t,a){let d=(u,f)=>fetch(`${e}${n}`,{method:"POST",headers:{...t,Authorization:`Bearer ${u}`,"Content-Type":"application/json"},body:JSON.stringify(f),signal:a}),i=await s.get(),r=await d(i,o);if(r.status===401&&(await r.body?.cancel(),i=await s.renew(i),r=await d(i,o)),r.status===401)throw await r.body?.cancel(),new Error("Databricks rejected the sign-in. Run `ug configure --agents copilot` to sign in again, then retry.");if(!r.ok||!r.body){let u=(await r.text()).slice(0,300),f=Number(/max_tokens: \d+ > (\d+)/.exec(u)?.[1]);if(!f)throw new Error(`AI Gateway returned ${r.status}: ${u}`);if(r=await d(i,{...o,max_tokens:f}),!r.ok||!r.body)throw new Error(`AI Gateway returned ${r.status}: ${(await r.text()).slice(0,300)}`)}if(r.headers.get("content-type")?.startsWith("text/html"))throw await r.body.cancel(),new Error("AI Gateway answered with a web page instead of the model's reply. Run `ug configure --agents copilot` again, then retry.");let c=new TextDecoder,p="";for await(let u of r.body){p+=c.decode(u,{stream:!0});let f=p.split(` +`);p=f.pop()??"";for(let k of f){let y=V(k);if(y==="[DONE]")return;y&&(yield JSON.parse(y))}}let g=V(p+c.decode());g&&g!=="[DONE]"&&(yield JSON.parse(g))}var V=e=>e.startsWith("data:")?e.slice(5).trim():"";var ke=["anthropic","completions","responses"],Q="Databricks-Model-Provider-Service",Me=e=>Object.fromEntries(["maxInputTokens","maxOutputTokens"].flatMap(n=>{let o=e[n];return typeof o=="number"&&Number.isInteger(o)&&o>0?[[n,o]]:[]})),Z="ug-config.json",ee=e=>{let n={};try{let t=JSON.parse(e??"{}");t&&typeof t=="object"&&!Array.isArray(t)&&(n=t)}catch{}let o=t=>n[t],s=o("models");return{workspace:String(o("workspace")??"").replace(/\/+$/,""),models:(Array.isArray(s)?s:[]).filter(t=>typeof t?.id=="string"&&t.id!==""&&ke.includes(t.api)).map(t=>({id:t.id,...typeof t.name=="string"&&t.name.trim()?{name:t.name.trim()}:{},...typeof t.family=="string"&&t.family?{family:t.family}:{},api:t.api,...Me(t)})),authCommand:String(o("authCommand")??""),provider:typeof o("provider")=="string"?String(o("provider")):"",headers:Ce(o("headers"))}},Ce=e=>e&&typeof e=="object"&&!Array.isArray(e)?Object.fromEntries(Object.entries(e).filter(n=>typeof n[1]=="string")):{},te=e=>`ucode/${M} githubcopilotchat${typeof e=="string"?`/${e}`:""}`,_=class{constructor(n,o,s=()=>({})){this.settings=n;this.log=o;this.headers=s}settings;log;headers;tokens;async provideLanguageModelChatInformation(){let{workspace:n,models:o,authCommand:s}=this.settings();return!n||!s||o.length===0?(this.log("No Databricks models: run `ug configure --agents copilot` to set them up."),[]):o.map(({id:t,name:a,family:d,api:i,maxInputTokens:r,maxOutputTokens:c})=>({id:t,api:i,name:a??t,family:d??t.split(".").pop()??t,version:M,detail:"Databricks",maxInputTokens:r??128e3,maxOutputTokens:c??32e3,capabilities:{toolCalling:!0,imageInput:i!=="completions"||t.includes("claude")}}))}async provideLanguageModelChatResponse(n,o,s,t,a){let{workspace:d,authCommand:i,provider:r,headers:c}=this.settings();this.tokens??=new b(i,m=>this.log(`Fetched a Databricks token (expires ${new Date(m).toISOString()})`));let p={model:n.id,messages:o,tools:s.tools??[],toolRequired:s.toolMode===h.LanguageModelChatToolMode.Required},g=new AbortController,u=a.onCancellationRequested(()=>g.abort()),f={...this.headers(),...c,...r?{[Q]:r}:{}},k=this.tokens,y=(m,oe,se={})=>Y(d,m,oe,k,{...f,...se},g.signal),P=r?"/ai-gateway/openai/v1":"/ai-gateway/mlflow/v1",ne={anthropic:()=>K(y("/ai-gateway/anthropic/v1/messages",W(p,n.maxOutputTokens),{"anthropic-version":"2023-06-01"})),responses:()=>B(y(`${P}/responses`,U(p))),completions:()=>H(y(`${P}/chat/completions`,F(p)))};try{for await(let m of ne[n.api]())t.report(m)}catch(m){if(!a.isCancellationRequested)throw this.log(`${n.id} failed: ${m instanceof Error?m.message:String(m)}`),m}finally{u.dispose()}}async provideTokenCount(n,o){return J(o)}},Te=e=>{try{return(0,z.readFileSync)(e,"utf8")}catch{return}};function Re(e){let n=h.window.createOutputChannel("Unity Gateway",{log:!0}),o=ee(Te((0,X.join)(e.extensionPath,Z))),s=()=>({"User-Agent":te(h.extensions.getExtension("GitHub.copilot-chat")?.packageJSON?.version)}),t=new _(()=>o,a=>n.info(a),s);e.subscriptions.push(n,h.lm.registerLanguageModelChatProvider("databricks-unity-gateway",t))}0&&(module.exports={CONFIG_FILE,DatabricksModels,PROVIDER_HEADER,activate,readConfig,userAgent}); diff --git a/tests/test_agent_vscode.py b/tests/test_agent_vscode.py index c9e514908..27bb4db8f 100644 --- a/tests/test_agent_vscode.py +++ b/tests/test_agent_vscode.py @@ -130,6 +130,14 @@ def test_installs_the_extension_with_the_workspace_and_its_claude_and_gpt_models ) assert "Next, in VS Code: 1. If VS Code is open," in printed + def test_selects_the_workspace_on_a_host_that_serves_several(self, code): + assert vscode.configure({**STATE, "workspace_id": "6051921418418893"}) + + assert code.config["headers"] == { + "X-Databricks-Org-Id": "6051921418418893", + "X-Databricks-Workspace-Id": "6051921418418893", + } + def test_models_with_the_same_label_show_their_ids(self, code, monkeypatch): monkeypatch.setattr( vscode, diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 3d01dfbf8..72e27e734 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -33,6 +33,8 @@ export interface Settings { readonly authCommand: string; /** The Model Provider Service the models belong to, or "" for the workspace's own models. */ readonly provider: string; + /** Selects the workspace on a host that serves several; empty otherwise. */ + readonly headers: Readonly>; } /** Routes a request to a Model Provider Service; its models aren't served without it. */ @@ -78,9 +80,15 @@ export const readConfig = (text: string | undefined): Settings => { })), authCommand: String(get("authCommand") ?? ""), provider: typeof get("provider") === "string" ? String(get("provider")) : "", + headers: stringValues(get("headers")), }; }; +const stringValues = (value: unknown): Record => + value && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string")) + : {}; + /** The gateway attributes requests by this User-Agent. */ export const userAgent = (copilotChatVersion: unknown): string => `ucode/${version} githubcopilotchat${typeof copilotChatVersion === "string" ? `/${copilotChatVersion}` : ""}`; @@ -124,12 +132,12 @@ export class DatabricksModels implements vscode.LanguageModelChatProvider progress: vscode.Progress, token: vscode.CancellationToken, ): Promise { - const { workspace, authCommand, provider } = this.settings(); + const { workspace, authCommand, provider, headers: selector } = this.settings(); this.tokens ??= new Tokens(authCommand, (expiresAt) => this.log(`Fetched a Databricks token (expires ${new Date(expiresAt).toISOString()})`)); const turn = { model: model.id, messages, tools: options.tools ?? [], toolRequired: options.toolMode === vscode.LanguageModelChatToolMode.Required }; const abort = new AbortController(); const cancelled = token.onCancellationRequested(() => abort.abort()); - const headers = { ...this.headers(), ...(provider ? { [PROVIDER_HEADER]: provider } : {}) }; + const headers = { ...this.headers(), ...selector, ...(provider ? { [PROVIDER_HEADER]: provider } : {}) }; const tokens = this.tokens; const stream = (path: string, body: object, extra: Record = {}) => streamEvents(workspace, path, body, tokens, { ...headers, ...extra }, abort.signal); diff --git a/vscode-extension/src/gateway.ts b/vscode-extension/src/gateway.ts index 549a4170c..4f1451542 100644 --- a/vscode-extension/src/gateway.ts +++ b/vscode-extension/src/gateway.ts @@ -40,6 +40,11 @@ export async function* streamEvents( throw new Error(`AI Gateway returned ${response.status}: ${(await response.text()).slice(0, 300)}`); } } + if (response.headers.get("content-type")?.startsWith("text/html")) { + await response.body.cancel(); + // What a host that serves several workspaces answers when it can't tell which one is meant. + throw new Error("AI Gateway answered with a web page instead of the model's reply. Run `ug configure --agents copilot` again, then retry."); + } const decoder = new TextDecoder(); let buffer = ""; for await (const chunk of response.body) { diff --git a/vscode-extension/test/extension.test.ts b/vscode-extension/test/extension.test.ts index ea35f33fa..0644b541d 100644 --- a/vscode-extension/test/extension.test.ts +++ b/vscode-extension/test/extension.test.ts @@ -12,8 +12,8 @@ import { LanguageModelChatToolMode, LanguageModelTextPart } from "./vscode"; const sse = (...data: string[]) => data.map((item) => `data: ${item}\n\n`).join(""); /** Replaces fetch with one that answers each request with the next status and SSE body. */ -const stubFetch = (...answers: [number, string][]) => { - const requests: { url: string; authorization: string; userAgent: string; provider: string | null; body: Record }[] = []; +const stubFetch = (...answers: [number, string, string?][]) => { + const requests: { url: string; authorization: string; userAgent: string; provider: string | null; orgId: string | null; body: Record }[] = []; globalThis.fetch = (async (url: string, init: RequestInit) => { const headers = new Headers(init.headers); requests.push({ @@ -21,13 +21,17 @@ const stubFetch = (...answers: [number, string][]) => { authorization: headers.get("authorization") ?? "", userAgent: headers.get("user-agent") ?? "", provider: headers.get(PROVIDER_HEADER), + orgId: headers.get("x-databricks-org-id"), body: JSON.parse(String(init.body)), }); - const [status, body] = answers[Math.min(requests.length - 1, answers.length - 1)] ?? [500, ""]; + const [status, body, contentType] = answers[Math.min(requests.length - 1, answers.length - 1)] ?? [500, ""]; // Split the body mid-line so the reader has to join chunks. const bytes = new TextEncoder().encode(body); const middle = Math.floor(bytes.length / 2); - return new Response(new Blob([bytes.slice(0, middle), bytes.slice(middle)]).stream(), { status }); + return new Response(new Blob([bytes.slice(0, middle), bytes.slice(middle)]).stream(), { + status, + ...(contentType ? { headers: { "content-type": contentType } } : {}), + }); }) as typeof fetch; return requests; }; @@ -84,6 +88,14 @@ describe("streamEvents", () => { assert.equal(requests.length, 2); }); + it("reports a web page, such as a sign-in page, instead of reading it as a cut-off reply", async () => { + stubFetch([200, "Databricks - Sign In", "text/html; charset=utf-8"]); + await rejectsWith( + collect(streamEvents("https://ws", "/r", {}, new Tokens(tokenCommand(["t"]).command), {}, new AbortController().signal)), + "AI Gateway answered with a web page instead of the model's reply. Run `ug configure --agents copilot` again, then retry.", + ); + }); + it("reports other gateway errors with their body", async () => { stubFetch([400, '{"message":"bad tools"}']); await rejectsWith( @@ -108,11 +120,12 @@ describe("userAgent", () => { }); describe("readConfig", () => { - it("keeps well-formed models, names, families and token limits, the provider, and trims the workspace URL", () => { + it("keeps well-formed models, names, families and token limits, the provider, string headers, and trims the workspace URL", () => { const config = { workspace: "https://ws/", authCommand: "ug auth-token", provider: "main.default.mps", + headers: { "X-Databricks-Org-Id": "123", "X-Bad": 7 }, models: [ { id: "a", api: "responses" }, { id: "gpt-5.5", family: "gpt-5.5", api: "responses" }, @@ -128,6 +141,7 @@ describe("readConfig", () => { workspace: "https://ws", authCommand: "ug auth-token", provider: "main.default.mps", + headers: { "X-Databricks-Org-Id": "123" }, models: [ { id: "a", api: "responses" }, { id: "gpt-5.5", family: "gpt-5.5", api: "responses" }, @@ -139,7 +153,7 @@ describe("readConfig", () => { }); it("gives no models for a missing or malformed file", () => { - const none = { workspace: "", authCommand: "", models: [], provider: "" }; + const none = { workspace: "", authCommand: "", models: [], provider: "", headers: {} }; for (const text of [undefined, "", "{", "[]", "null", '"text"']) { assert.deepEqual(readConfig(text), none, String(text)); } @@ -147,10 +161,11 @@ describe("readConfig", () => { }); describe("DatabricksModels", () => { - const settings = (command: string, provider = ""): Settings => ({ + const settings = (command: string, provider = "", headers: Record = {}): Settings => ({ workspace: "https://ws", authCommand: command, provider, + headers, models: [ { id: "system.ai.claude-sonnet-5-5", name: "Claude Sonnet 5.5", api: "anthropic", maxInputTokens: 872_000, maxOutputTokens: 128_000 }, { id: "system.ai.gpt-6-luna", api: "responses" }, @@ -221,4 +236,13 @@ describe("DatabricksModels", () => { ], ); }); + + it("selects the workspace with the configured headers on every API", async () => { + const requests = stubFetch(...answers); + await askEach(new DatabricksModels(() => settings(tokenCommand(["t"]).command, "", { "X-Databricks-Org-Id": "123" }), () => {})); + assert.deepEqual( + requests.map((request) => request.orgId), + ["123", "123", "123"], + ); + }); });