Skip to content
Merged
28 changes: 28 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ on:
description: Exact OpenCode version (non-blocking lane)
default: 1.18.31
required: true
copilot_version:
description: Exact GitHub Copilot CLI version (non-blocking lane)
default: 1.0.91
required: true
dependency:
description: Optional exact dependency for reproduction (e.g. tomlkit==0.14.0)
default: ''
Expand All @@ -53,6 +57,7 @@ env:
CLAUDE_VERSION: ${{ inputs.claude_version || '2.1.290' }}
CODEX_VERSION: ${{ inputs.codex_version || '0.154.0' }}
OPENCODE_VERSION: ${{ inputs.opencode_version || '1.18.31' }}
COPILOT_VERSION: ${{ inputs.copilot_version || '1.0.91' }}
PACKAGE_INDEX: ${{ inputs.default_index || 'https://pypi.org/simple' }}

jobs:
Expand Down Expand Up @@ -211,6 +216,7 @@ jobs:
claude) args=(--claude-version "$CLAUDE_VERSION") ;;
codex) args=(--codex-version "$CODEX_VERSION") ;;
opencode) args=(--opencode-version "$OPENCODE_VERSION") ;;
copilot) args=(--copilot-version "$COPILOT_VERSION") ;;
esac
if [[ -n "$DEPENDENCY" ]]; then
args+=(--dependency "$DEPENDENCY")
Expand Down Expand Up @@ -351,6 +357,28 @@ jobs:
ARTIFACT_NAME: integration-full-opencode-${{ matrix.os }}
steps: *live-steps

# Non-blocking pending stabilization, like OpenCode: continue-on-error and absent from the
# cujs `needs`.
copilot:
name: Full journeys · Copilot · ${{ matrix.os == 'windows-latest' && 'Windows' || 'Linux' }} (non-blocking)
if: ${{ !cancelled() && needs.workspace.result == 'success' }}
needs: workspace
continue-on-error: true
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, windows-latest]
timeout-minutes: 30
env:
UCODE_TEST_WORKSPACE: ${{ secrets.UCODE_TEST_WORKSPACE }}
DATABRICKS_BEARER: ${{ secrets.DATABRICKS_BEARER }}
DEPENDENCY: ${{ inputs.dependency }}
AGENT: copilot
TEST_MARKER: live and copilot
ARTIFACT_NAME: integration-full-copilot-${{ matrix.os }}
steps: *live-steps

# Non-blocking pending stabilization: continue-on-error and absent from the cujs `needs`.
# Runs on the managed e2e workspace with an injected config that enables Claude/Codex but not
# OpenCode (a service principal authenticates it, like the `managed` lane).
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ because it overrides the model sent to the gateway. Restart Copilot through `ug`
to change the wire model or API; in-session model selection does not rebuild its
provider configuration.

`ug copilot` runs Copilot against a loopback proxy for the session. Copilot holds only
a random session secret; the proxy adds a fresh Databricks token to each gateway
request, so OAuth sessions keep working past token expiry without a restart, and no
token is written to `~/.copilot/ucode.env`. If the Databricks sign-in itself expires,
Copilot shows a "Databricks sign-in expired" error; run `databricks auth login` and
restart `ug copilot`. Proxy errors are logged to `~/.ucode/logs/copilot-proxy.log`.

Without a managed workspace config, `ug claude` automatically discovers gateway
models for Claude Code's `/model` picker. Discovery defaults to `system.ai` when
no provider or model location is selected. Use `--provider` or `--model-location`
Expand Down
36 changes: 29 additions & 7 deletions scripts/run_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"claude": "@anthropic-ai/claude-code",
"codex": "@openai/codex",
"opencode": "opencode-ai",
"copilot": "@github/copilot",
}
WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD"
# Claude exports no spans on Windows, where ug writes no machine-wide Claude settings.
Expand All @@ -44,9 +45,18 @@
"claude": "test_ug_claude_headless.py::test_ug_claude_headless_prompt_argument",
"codex": "test_ug_codex_headless.py::test_ug_codex_headless_prompt_argument",
"opencode": "test_ug_opencode_headless.py::test_ug_opencode_headless_prompt_argument",
"copilot": "test_ug_copilot_headless.py::test_ug_copilot_headless_completes_file_task",
}


def agent_version_output(agent: str, output: str) -> str:
"""The agent's ``--version`` text, normalized for exact-version matching."""
if agent == "copilot":
# "GitHub Copilot CLI 1.0.91." plus an update hint on later lines.
return output.splitlines()[0].removesuffix(".") if output else output
return output


def redact_secrets(value: str, secrets: Iterable[str]) -> str:
for secret in sorted({secret for secret in secrets if secret}, key=len, reverse=True):
value = value.replace(secret, "<redacted>")
Expand Down Expand Up @@ -253,11 +263,13 @@ def arguments(
parser.add_argument("--claude-version", type=exact_npm_version)
parser.add_argument("--codex-version", type=exact_npm_version)
parser.add_argument("--opencode-version", type=exact_npm_version)
parser.add_argument("--copilot-version", type=exact_npm_version)
parser.add_argument("--claude-model", default=environment.get("UG_INTEGRATION_CLAUDE_MODEL"))
parser.add_argument("--codex-model", default=environment.get("UG_INTEGRATION_CODEX_MODEL"))
parser.add_argument(
"--opencode-model", default=environment.get("UG_INTEGRATION_OPENCODE_MODEL")
)
parser.add_argument("--copilot-model", default=environment.get("UG_INTEGRATION_COPILOT_MODEL"))
parser.add_argument(
"--claude-provider",
default="main.ucode.ci_e2e_anthropic_nonrelay_mps",
Expand Down Expand Up @@ -356,9 +368,12 @@ def arguments(
args.pytest_args.extend([flag, str(value)])
if selected.x:
args.pytest_args.append("-x")
if not (args.claude_version or args.codex_version or args.opencode_version):
if not (
args.claude_version or args.codex_version or args.opencode_version or args.copilot_version
):
parser.error(
"Select --claude-version, --codex-version and/or --opencode-version explicitly."
"Select --claude-version, --codex-version, --opencode-version and/or "
"--copilot-version explicitly."
)
if args.ug_version != "checkout" and not re.fullmatch(
r"[0-9][0-9A-Za-z.!+_-]*", args.ug_version
Expand Down Expand Up @@ -516,9 +531,11 @@ def run(command, *, cwd=output, env=base_env, timeout=600) -> str:
"claude": args.claude_version,
"codex": args.codex_version,
"opencode": args.opencode_version,
"copilot": args.copilot_version,
"claude_model": args.claude_model,
"codex_model": args.codex_model,
"opencode_model": args.opencode_model,
"copilot_model": args.copilot_model,
"claude_provider": args.claude_provider,
"claude_relayed_provider": args.claude_relayed_provider,
"claude_provider_model": args.claude_provider_model,
Expand Down Expand Up @@ -731,7 +748,9 @@ def run(command, *, cwd=output, env=base_env, timeout=600) -> str:
report["agents"] = {}
for agent in agents:
agent_command = npm_executable(agent_bin, agent)
version = run([agent_command, "--version"], env=runtime_env, timeout=30)
version = agent_version_output(
agent, run([agent_command, "--version"], env=runtime_env, timeout=30)
)
expected = getattr(args, f"{agent}_version")
if not re.search(rf"(?<![\w.]){re.escape(expected)}(?![\w.])", version):
raise RuntimeError(f"Expected {agent} {expected}, got {version!r}")
Expand Down Expand Up @@ -877,10 +896,13 @@ def run(command, *, cwd=output, env=base_env, timeout=600) -> str:
raise RuntimeError("Pytest returned success without a test report.")
# A bootstrap/update path must not silently alter the selected agent version.
for agent in agents:
after = run(
[npm_executable(agent_bin, agent), "--version"],
env=runtime_env,
timeout=30,
after = agent_version_output(
agent,
run(
[npm_executable(agent_bin, agent), "--version"],
env=runtime_env,
timeout=30,
),
)
if after != report["agents"][agent]:
raise RuntimeError(f"{agent} changed version during the suite: {after}")
Expand Down
2 changes: 1 addition & 1 deletion src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ def configure_tool(
result = kilo.write_tool_config(state, model)
else:
result = opencode.write_tool_config(state, model)
# gemini/opencode/copilot/pi return (state, token); codex/claude return state
# gemini/opencode/kilo/pi return (state, token); codex/claude/copilot return state
if isinstance(result, tuple):
return result[0]
return result
Expand Down
15 changes: 2 additions & 13 deletions src/ucode/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,14 @@
CUSTOM_OAUTH_TIMEOUT_MS,
CustomOAuthConfig,
build_custom_auth_token_argv,
get_custom_client_token,
launch_token,
)
from ucode.databricks import (
CodexCatalogSource,
CodexMpsModelCatalogUnavailable,
_fetch_codex_model_catalog,
build_auth_token_argv,
build_tool_base_url,
get_databricks_token,
ug_binary,
)
from ucode.launcher import exec_or_spawn
Expand Down Expand Up @@ -1229,17 +1228,7 @@ def _launch_token(state: dict, workspace: str, *, force_refresh: bool = False) -
"""The token Codex authenticates with: a custom-OAuth client token when configured,
else the CLI profile. The single auth-selection point — the OTLP proxy's token
provider (_otel_token_provider) delegates here so both mint the same principal."""
custom_oauth = state.get("custom_oauth")
if isinstance(custom_oauth, dict):
return get_custom_client_token(
workspace,
custom_oauth["client_id"],
custom_oauth["redirect_url"],
scopes=custom_oauth["scopes"],
profile=custom_oauth.get("profile"),
force_refresh=force_refresh,
)
return get_databricks_token(workspace, state.get("profile"), force_refresh=force_refresh)
return launch_token(state, workspace, force_refresh=force_refresh)


def _tool_args_select_model(tool_args: list[str]) -> bool:
Expand Down
Loading
Loading