Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -465,7 +465,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 @@ -1181,17 +1180,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