Skip to content
Merged
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
25 changes: 20 additions & 5 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,8 @@ integration utilities; only CUJ-specific evidence correlation stays in a test fi
| `test_ug_configure_codex_databricks` | Configure Databricks Hosted; execute the generated auth helper; open Codex TUI and read a file | Generated helper invokes `ug` with clean token stdout; completed assistant answer contains the file value; normal exit and reopen |
| `test_ug_configure_codex_openai_mps` | Select OpenAI MPS in the real configure picker; launch Codex | Saved provider in status; completed TUI file task; normal exit |
| `test_ug_claude_custom_oauth_cli_boots`, `test_ug_codex_custom_oauth_cli_boots` | Launch with `ENABLE_CUSTOM_OAUTH_FROM_CLI=1`, `--workspace`, and `--client-id databricks-cli` | Real TUI reaches a usable prompt, accepts keyboard input, exits normally, and saves `client_id = databricks-cli` in its generated CLI profile; Claude also reads the OS-managed settings and requires a profile-only `apiKeyHelper` |
| `test_case_07_configured_claude_discovers_system_models`, `test_case_09_fresh_claude_discovers_system_models` | Launch configured/fresh Claude with no discovery flag or source override | Claude caches `system.ai` models (including recognized Anthropic gateway aliases), includes ug's discovered family defaults, and shows a discovered picker entry |
| `test_case_08_configured_codex_uses_default_models`, `test_case_10_fresh_codex_uses_default_models` | Launch configured/fresh Codex with no source override | ug discovers `system.ai` models but leaves model/reasoning preferences unset; app-server exposes native GPT entries without a generated scoped catalog |
| `test_case_07_configured_claude_discovers_system_models` (CUJ7), `test_case_09_fresh_claude_discovers_system_models` (integration) | Launch configured/fresh Claude with no discovery flag or source override | Claude caches `system.ai` models (including recognized Anthropic gateway aliases), includes ug's discovered family defaults, and shows a discovered picker entry; case 07 lives in `e2e_cuj/test_cuj7_model_discovery.py` |
| `test_case_08_configured_codex_uses_default_models` (CUJ7), `test_case_10_fresh_codex_uses_default_models` (integration) | Launch configured/fresh Codex with no source override | ug discovers `system.ai` models but leaves model/reasoning preferences unset; app-server exposes native GPT entries without a generated scoped catalog; case 08 lives in `e2e_cuj/test_cuj7_model_discovery.py` |
| `test_case_11_*` | Launch configured and fresh Claude with an explicit provider and no managed config | The provider catalog replaces built-in picker rows; the cache contains exactly the provider model, and the picker shows both its row and Default resolving to it |
| `test_case_12_*` | Launch configured and fresh Codex with a provider | The provider supplies exactly its model catalog |
| `test_case_13_*` | Launch configured and fresh Claude with a model location and no managed config | The explicit parent's catalog replaces built-in picker rows and appears in the real picker; Default resolves to the model in the scoped fixture catalog |
Expand All @@ -243,9 +243,14 @@ integration utilities; only CUJ-specific evidence correlation stays in a test fi
| `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 |
| `test_ug_agents_admin_managed_guardrails` | `ug agents add` / `remove` on an agent the admin config enables | Add is a no-op noting the admin manages it; remove is rejected with nonzero exit |
| `test_ug_claude_headless_fresh_workspace`, `test_ug_codex_headless_fresh_workspace` | From fresh state, launch an agent with `--workspace` against a workspace with no managed config | Claude Haiku 4.5 and Codex GPT-5.4 Nano read an unpredictable file value through the gateway and return it in a structured completed answer; exit zero; no routing |
| `test_ug_claude_fresh_provider_launch`, `test_ug_codex_fresh_provider_launch` | From fresh state, launch each real agent CLI with `--workspace` and an existing provider | Both CLIs report the selected provider and exit successfully without routing or inference; Claude also checks its provider header. Cases 11/12 cover provider discovery |
| `test_ug_claude_headless_fresh_model_location` | From fresh state, launch Claude in the CUJ7 workspace with `--workspace`, `--model-location ug_e2e.models`, and `ug_e2e.models.claude_haiku` | Haiku reads an unpredictable file value through the selected model location and returns it in a structured completed answer; exit zero; no routing |
| `test_ug_codex_headless_fresh_model_location` | From fresh state, launch Codex in the CUJ7 workspace with `--workspace`, `--model-location ug_e2e.models`, and `ug_e2e.models.gpt_luna` | Luna reads an unpredictable file value through the selected model location and returns it in a structured completed answer; exit zero; no routing |
| `test_ug_claude_preserves_preexisting_managed_family_defaults` | Launch fresh Claude with pre-existing OS-managed family defaults and select the Sonnet family | Every default survives unchanged; the selected family completes a file task through the preconfigured Sonnet service |
| `test_ug_claude_exports_trace_to_configured_table`, `test_ug_codex_exports_trace_to_configured_table` | Configure tracing, complete a headless task carrying a unique trace marker, then wait for ingestion | The configured trace table contains an agent span with the same trace-safe marker and requested model |
| `test_ug_claude_headless_explicit_model_bypasses_routing` | Pass `--model VALUE` / `--model=VALUE` before and after ug's separator with smart routing enabled, without workspace policy | Real file task completes; JSON `modelUsage` reports the requested model with output tokens; no routing wrapper |
| `test_ug_codex_headless_explicit_model_bypasses_routing` | Pass `--model VALUE` / `--model=VALUE` / `-m VALUE` with smart routing enabled | Real file task completes; no routing wrapper |
| `test_ug_codex_headless_explicit_model_bypasses_routing` | Pass `--model VALUE` / `--model=VALUE` / `-m VALUE` before ug's separator and within Codex `exec`, with smart routing enabled | Real file task completes; completed-turn model matches the selection; no routing wrapper |
| `test_ug_claude_preserves_caller_settings_and_hook` | Pass a settings path containing spaces | Real SessionStart hook executes; caller file unchanged; file task completes |
| `test_ug_claude_reports_unsupported_short_model_option` | Pass Claude's unsupported `-m` | Actual agent error and exit status preserved |
| `test_ug_claude_auth_help`, `test_ug_claude_mcp_help` | Request subcommand help with legacy smart routing off/on | Real agent help; no routing wrapper |
Expand Down Expand Up @@ -274,7 +279,7 @@ integration utilities; only CUJ-specific evidence correlation stays in a test fi
| `test_ug_and_ucode_auth_helpers_emit_only_the_supplied_bearer` | Run both auth helper commands with the public bearer override, with and without forced refresh | Exact token-only stdout, no warnings or ANSI escapes; no workspace authentication or saved state |
| `test_ug_and_ucode_web_search_helpers_preserve_mcp_stdio` | Initialize and list tools through both web-search helper commands | Exactly the MCP JSON-RPC responses; no text/ANSI contamination; existing server/tool identities preserved; no model request |

With Claude and Codex selected there are **80 live cases** (14 marked TUI cases),
With Claude and Codex selected there are **85 live cases** (11 marked TUI cases),
**1 two-workspace case** (marker `workspace_switch`),
**43 managed-fixture cases** (marker `managed_fixture`, with only
the CodingAgentConfig input injected from a JSON file in `fixtures/managed_config/`), and **7 installation checks**. The 14 retained numbered scenarios
Expand Down Expand Up @@ -338,10 +343,20 @@ dependency graph to reproduce a user's combination. Every relevant same-reposito
PR and push to `main` runs both smoke and the full CUJ suite. Smoke covers the
Databricks Hosted configure/TUI, custom OAuth CLI TUI, and headless argument
journeys for both agents, in two parallel jobs. After smoke finishes, the full
suite runs all 80 live cases across two parallel agent jobs: one Claude VM and one
suite runs all 85 live cases across two parallel agent jobs: one Claude VM and one
Codex VM, each running its configure, headless, and commands/lifecycle cases
serially. Each agent is installed once for the full suite, and no two full jobs
for the same agent overlap within a run.
CUJ7's five journeys collect eight cases in the required
`E2E CUJs · CUJ 7 · Unmanaged model discovery` shard: configured Claude picker and
Codex app-server discovery, fresh Claude/Codex model-location file tasks, and Claude
managed-family-default preservation on launch. Task journeys cover model selection before
and after ug's separator. Each case reverts its local setup. The class
pins `WORKSPACE_URL` to `https://dbc-14e376e8-6541.cloud.databricks.com` rather than using
a workspace secret. The workspace must publish no `CodingAgentConfig`, expose discoverable
`system.ai` models, and retain the existing models `ug_e2e.models.claude_haiku`,
`ug_e2e.models.claude_sonnet`, and `ug_e2e.models.gpt_luna`; the shared service principal
needs read/use privileges on all three.
CI starts integration alongside unit tests and the existing e2e shards. Integration
does not wait for agent e2e or get skipped when an agent shard fails. These suites
share workspace capacity; overlapping their requests can still encounter rate limits.
Expand Down
11 changes: 2 additions & 9 deletions tests/e2e_cuj/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
from .helpers.constants import CLAUDE, CODEX, MANAGED_PATHS
from .helpers.session import (
MACHINE_WIDE_LEAK,
MachineWideLeak,
UserSession,
dirty_runner_message,
record_machine_wide_leak,
)
from .helpers.tui_request_recorder import TuiRequestRecorder
from .helpers.workspace import Workspace
Expand All @@ -31,13 +31,6 @@ def _scrubbed(error: DatabricksError) -> RuntimeError:
return RuntimeError(f"Workspace API failed: {type(error).__name__}")


def _record_machine_wide_leak(request):
"""Name the class that leaked machine-wide settings to the later classes it blocks."""
leaked = tuple(str(path) for path in MANAGED_PATHS if path.exists())
if leaked:
request.config.stash[MACHINE_WIDE_LEAK] = MachineWideLeak(request.node.nodeid, leaked)


@pytest.fixture(scope="class")
def cuj(request, setup_workspace, tmp_path_factory):
assert os.name == "posix", "Full TUI CUJs require a disposable POSIX runner"
Expand Down Expand Up @@ -70,7 +63,7 @@ def cuj(request, setup_workspace, tmp_path_factory):
"cleanup-revert", "CUJ teardown left machine-wide agent settings"
)
finally:
_record_machine_wide_leak(request)
record_machine_wide_leak(request)
except DatabricksError as error:
raise _scrubbed(error) from None

Expand Down
4 changes: 4 additions & 0 deletions tests/e2e_cuj/helpers/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ class CodingAgent(StrEnum):
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
)
UC_MODEL_LOCATION_FIXTURE = ("ug_e2e.models", "ug_e2e.models.codex_primary")
MODEL_SERVICE_SCHEMA = "ug_e2e.models"
CLAUDE_HAIKU_MODEL_SERVICE = f"{MODEL_SERVICE_SCHEMA}.claude_haiku"
CLAUDE_SONNET_MODEL_SERVICE = f"{MODEL_SERVICE_SCHEMA}.claude_sonnet"
CODEX_LUNA_MODEL_SERVICE = f"{MODEL_SERVICE_SCHEMA}.gpt_luna"
FIXTURE_READER_MCP_SERVICE_NAME = "ug_e2e.tools.fixture_reader"
# UC skill names reject underscores, so the plan's `fixture_summary` is hyphenated.
FIXTURE_SUMMARY_SKILL_NAME = "ug_e2e.skills.fixture-summary"
Expand Down
65 changes: 64 additions & 1 deletion tests/e2e_cuj/helpers/evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
import httpx

from tests.integration.utils.evidence import FileTask, read_jsonl
from tests.integration.utils.model_discovery import claude_discovery_model_id
from tests.integration.utils.provider_catalog import MODEL_SERVICE_PARENT_SCHEMA_HEADER

from .constants import CLAUDE, CODEX, NATIVE_MODEL_ALIASES
from .constants import CLAUDE, CODEX, INFERENCE_PATHS, NATIVE_MODEL_ALIASES


def canonical_model(value):
Expand Down Expand Up @@ -114,6 +116,67 @@ def served_inference_request(recorder, requests, request, agent):
raise AssertionError("Claude compatibility retries did not reach a successful response")


def assert_claude_headless_model(result, expected):
final = None
for line in result.stdout.splitlines():
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(payload, dict) and payload.get("type") == "result":
final = payload
assert final is not None and not final.get("is_error"), result.stdout
usage = final["modelUsage"]
assert set(usage) == {expected}, {"expected": expected, "observed": sorted(usage)}
assert usage[expected]["outputTokens"] > 0, usage


def _request_contains_task(request, agent, task):
field = "messages" if agent == CLAUDE else "input"
entries = request.payload.get(field)
if not isinstance(entries, (list, str)):
return False
if isinstance(entries, str):
return entries == task.prompt
for entry in entries:
if not isinstance(entry, dict) or entry.get("role") != "user":
continue
content = entry.get("content", "")
if isinstance(content, str) and content == task.prompt:
return True
if isinstance(content, list) and any(
message_text([part]) == task.prompt for part in content if isinstance(part, dict)
):
return True
return False


def assert_inference_evidence(recorder, checkpoint, agent, task, expected, *, parent_schema=None):
expected_wire_model = claude_discovery_model_id(expected) if agent == CLAUDE else expected
requests = recorder.requests_after(checkpoint)
inference_requests = [
request
for request in requests
if request.method == "POST" and request.path == INFERENCE_PATHS[agent]
]
assert inference_requests, {
"agent": agent,
"path": INFERENCE_PATHS[agent],
"requests": [(request.method, request.path) for request in requests],
}
task_requests = [
request for request in inference_requests if _request_contains_task(request, agent, task)
]
assert task_requests, "No inference request contained the submitted task prompt"
for request in task_requests:
if parent_schema is not None:
assert (
request.headers.get(MODEL_SERVICE_PARENT_SCHEMA_HEADER.lower()) == parent_schema
), request.headers
served = served_inference_request(recorder, task_requests, request, agent)
assert_served(recorder, served, expected_wire_model)


def claude_file_task(session):
"""A FileTask naming its absolute path, so Claude reads it without a `find` permission prompt."""
task = FileTask(session)
Expand Down
7 changes: 7 additions & 0 deletions tests/e2e_cuj/helpers/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ class MachineWideLeak:
MACHINE_WIDE_LEAK = pytest.StashKey[MachineWideLeak]()


def record_machine_wide_leak(request):
"""Name the scenario that left machine-wide settings in subsequent setup failures."""
leaked = tuple(str(path) for path in constants.MANAGED_PATHS if path.exists())
if leaked:
request.config.stash[MACHINE_WIDE_LEAK] = MachineWideLeak(request.node.nodeid, leaked)


def dirty_runner_message(leak: MachineWideLeak | None) -> str:
if leak is None:
return (
Expand Down
1 change: 1 addition & 0 deletions tests/e2e_cuj/pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ markers =
mcp_registration: managed MCP registration, connection, and tool use
workspace_isolated: reads preconfigured CUJ workspace state with isolated local sessions
tui: real interactive terminal and completed task
live: requires the CUJ's real workspace
claude: only runs when Claude Code is explicitly selected
codex: only runs when Codex is explicitly selected
Loading
Loading