Skip to content
Merged
70 changes: 55 additions & 15 deletions src/ucode/smart_routing/v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
list_anthropic_model_catalog,
list_anthropic_models,
)
from ucode.launcher import exec_or_spawn
from ucode.smart_routing import claude_routing, codex_interposer, routing
from ucode.smart_routing.claude_hooks import (
FIRST_PROMPT_SOCKET_ENV,
Expand All @@ -39,8 +40,11 @@
from ucode.ui import print_warning

ENABLE_SMART_ROUTING_ENV_VAR = "ENABLE_SMART_ROUTING_V2"
ENABLE_SUBAGENT_ROUTING_ENV_VAR = "ENABLE_SMART_ROUTING_SUBAGENT_ONLY"
LEGACY_STATE_KEY = "smart_routing_enabled"

_SMART_ROUTING_ENV_VARS = (ENABLE_SMART_ROUTING_ENV_VAR, ENABLE_SUBAGENT_ROUTING_ENV_VAR)

CODEX_INTERPOSER_LOG = APP_DIR / "codex-v2-interposer.log"

CLAUDE_TARGET_MODEL = "system.ai.claude-sonnet-4-6[1m]" # TODO(lilly): replace with smart router.
Expand Down Expand Up @@ -115,32 +119,46 @@ def _model_picker_catalog() -> AnthropicModelCatalog | None:

def smart_routing_enabled(env: MutableMapping[str, str] | None = None) -> bool:
source = os.environ if env is None else env
return source.get(ENABLE_SMART_ROUTING_ENV_VAR) == "1"
return any(source.get(var) == "1" for var in _SMART_ROUTING_ENV_VARS)


def first_prompt_routing_enabled(env: MutableMapping[str, str] | None = None) -> bool:
"""Whether the first prompt is routed. Subagent-only wins over the full V2 flag."""
source = os.environ if env is None else env
return (
source.get(ENABLE_SMART_ROUTING_ENV_VAR) == "1"
and source.get(ENABLE_SUBAGENT_ROUTING_ENV_VAR) != "1"
)


def enable_smart_routing(env: MutableMapping[str, str] | None = None) -> str | None:
"""Set the only supported smart-routing env var and return its prior value."""
def enable_smart_routing(
env: MutableMapping[str, str] | None = None,
) -> dict[str, str | None]:
"""Set the full smart-routing env var and return the prior value of every routing var."""
target = os.environ if env is None else env
previous = target.get(ENABLE_SMART_ROUTING_ENV_VAR)
previous = {var: target.get(var) for var in _SMART_ROUTING_ENV_VARS}
target[ENABLE_SMART_ROUTING_ENV_VAR] = "1"
return previous


def restore_smart_routing_env(
previous: str | None, env: MutableMapping[str, str] | None = None
previous: dict[str, str | None], env: MutableMapping[str, str] | None = None
) -> None:
"""Restore the env state captured when smart routing was enabled or disabled."""
target = os.environ if env is None else env
if previous is None:
target.pop(ENABLE_SMART_ROUTING_ENV_VAR, None)
else:
target[ENABLE_SMART_ROUTING_ENV_VAR] = previous
for var, value in previous.items():
if value is None:
target.pop(var, None)
else:
target[var] = value


def disable_smart_routing(env: MutableMapping[str, str] | None = None) -> str | None:
"""Temporarily remove the smart-routing env var and return its prior value."""
def disable_smart_routing(
env: MutableMapping[str, str] | None = None,
) -> dict[str, str | None]:
"""Temporarily remove the smart-routing env vars and return their prior values."""
target = os.environ if env is None else env
return target.pop(ENABLE_SMART_ROUTING_ENV_VAR, None)
return {var: target.pop(var, None) for var in _SMART_ROUTING_ENV_VARS}


def _loopback_websocket_url(port: int) -> str:
Expand Down Expand Up @@ -445,6 +463,7 @@ def launch_claude(
)
model_ids = catalog.model_ids

route_first_prompt = first_prompt_routing_enabled()
run_id = f"{os.getpid()}-{uuid.uuid4().hex[:8]}"
socket_path = APP_DIR / f"claude-v2-{run_id}.sock"
settings_path = APP_DIR / f"claude-v2-{run_id}.json"
Expand All @@ -457,8 +476,11 @@ def launch_claude(
if not isinstance(env, dict):
raise RuntimeError("Claude settings 'env' must be an object for smart routing.")
env.pop("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", None)
env[ENABLE_SMART_ROUTING_ENV_VAR] = "1"
env[FIRST_PROMPT_SOCKET_ENV] = str(socket_path)
if route_first_prompt:
env[ENABLE_SMART_ROUTING_ENV_VAR] = "1"
env[FIRST_PROMPT_SOCKET_ENV] = str(socket_path)
else:
env[ENABLE_SUBAGENT_ROUTING_ENV_VAR] = "1"
model_overrides = settings.setdefault("modelOverrides", {})
if not isinstance(model_overrides, dict):
raise RuntimeError("Claude settings 'modelOverrides' must be an object for smart routing.")
Expand All @@ -468,12 +490,26 @@ def launch_claude(
"claude_models": {str(index): model for index, model in enumerate(model_ids)},
}
sync_smart_routing_hooks(settings, routing_state, enabled=True)
sync_first_prompt_hook(settings, hook_executable)
if route_first_prompt:
sync_first_prompt_hook(settings, hook_executable)
write_json_file(settings_path, settings)
model_args = launch_model_args(remaining, launch_model)
routed_agent_args = _with_routed_claude_agents(remaining, model_ids)
argv = [binary, "--settings", str(settings_path), *model_args, *routed_agent_args]

if not route_first_prompt:
# Subagent-only routing needs no PTY: the PreToolUse hooks ride in the
# per-launch settings, so spawn Claude directly and clean up after it.
proc = subprocess.Popen(argv)
try:
returncode = proc.wait()
except KeyboardInterrupt:
proc.send_signal(signal.SIGINT)
returncode = proc.wait()
finally:
settings_path.unlink(missing_ok=True)
sys.exit(returncode)

model_setting = _ClaudeModelSettingGuard(user_settings_path)

def route_prompt(prompt: str) -> claude_pty.FirstPromptRoute:
Expand Down Expand Up @@ -566,6 +602,10 @@ def launch_codex(
"PreToolUse": _v2_pre_tool_use_hooks(state, available_models),
}
config_args = codex_config_args(overlay)
if not first_prompt_routing_enabled():
# Subagent-only routing needs neither the app-server nor the interposer:
# the hooks ride in the CLI config, so launch the TUI directly.
exec_or_spawn([binary, *config_args, *tool_args])
app_port = _free_port()
app_server_url = _loopback_websocket_url(app_port)

Expand Down
10 changes: 6 additions & 4 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ All tests live directly in `integration/`; shared mechanics live in `utils/`.
| `test_ug_codex_app_help`, `test_ug_codex_app_server_help`, `test_ug_codex_exec_help`, `test_ug_codex_mcp_help` | Request subcommand help, routing off/on | Real agent help; no routing wrapper |
| `test_ug_codex_app_reports_unknown_argument` | Pass an invalid option directly to `ug codex app`, routing off/on | Real Codex parser error and status preserved |
| `test_ug_codex_app_server_client_initializes` | Connect a stdio client, direct/`--` separator, routing off/on | Actual JSON-RPC initialize response; no non-JSON stdout; no routing |
| `test_smart_routing_claude_route_subagent_hook`, `test_smart_routing_codex_route_subagent_hook` | Pipe a real PreToolUse spawn payload to the installed route-subagent hook with subagent-only routing enabled | Allow decision against the live router; requested model replaced by a routed agent definition (Claude) or bundled catalog slug (Codex) from the offered models; one audited decision matching the session and task |
| `test_smart_routing_claude_subagent_only_launch_shows_no_first_prompt_banner`, `test_smart_routing_codex_subagent_only_launch_shows_no_first_prompt_banner` | Configure, then launch the real TUI with both the full and subagent-only routing flags set and submit one file prompt | Subagent-only takes precedence: the prompt completes with no smart-routing banner and no first-prompt routing wrapper (PTY/interposer); Claude's SessionStart canary proves the routing hooks armed; normal exit |
| `test_ug_configure_claude_repeat_and_revert`, `test_ug_configure_codex_repeat_and_revert` | Configure twice over user settings; complete a task; revert twice | Settings preserved; no bearer in ug state; generated config removed; status unconfigured |
| `test_ug_configure_claude_rejects_invalid_credentials`, `test_ug_configure_codex_rejects_invalid_credentials` | Configure with a rejected bearer against the real workspace | Authentication failure; no successful saved setup |
| `test_ug_configure_managed_claude`, `test_ug_configure_managed_codex` | Configure against a workspace that publishes a managed CodingAgentConfig | No agent selector; each agent's generated config exposes exactly the admin's static model_services; real gateway prompt on launch |
Expand All @@ -65,11 +67,11 @@ All tests live directly in `integration/`; shared mechanics live in `utils/`.
| `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 both agents selected there are **41 live cases** (6 interactive TUI cases),
With both agents selected there are **46 live cases** (6 interactive TUI cases),
**4 managed-workspace cases** (marker `managed`, run against a separate workspace that
publishes a CodingAgentConfig), **34 managed-fixture cases** (marker `managed_fixture`, with only
publishes a CodingAgentConfig), **36 managed-fixture cases** (marker `managed_fixture`, with only
the CodingAgentConfig input injected), and **5 installation checks**. The 24 numbered scenarios
cover configured and fresh state across the Claude and Codex managed-discovery matrix; ten
cover configured and fresh state across the Claude and Codex managed-discovery matrix; twelve
existing collected cases cover focused model, MCP, skills, and lifecycle shapes. Parametrization
varies
argument spelling or routing mode, never hides the agent/provider in the test name. Duplicate boot-only cases
Expand Down Expand Up @@ -129,7 +131,7 @@ pending. The descriptive jobs provide the actual coverage and diagnostics.
| Provider switching, relayed/subscription MPS | Not covered by the four provider journeys |
| TUI initial prompt supplied on the launch command line | Not yet covered; headless prompt arguments are covered |
| Follow-up turns and conversation resume | Not covered; reopen proves startup, not conversation resume |
| Claude/Codex interactive smart routing | First-prompt routing covered by the `managed_fixture` smart-routing banner journeys; subagent routing, interactive explicit-model bypass, and dedicated routing CI shards remain deferred. Unit/component routing tests do not establish live routing behavior. |
| Claude/Codex interactive smart routing | First-prompt routing covered by the `managed_fixture` smart-routing banner journeys; subagent routing covered at the hook protocol level by the route-subagent hook journeys, which drive the real installed hook commands with a harness-shaped payload against the live router; the subagent-only launch journeys assert the first-prompt banner and routing wrappers stay silent while the routing hooks arm. The agent's interactive spawn decision, interactive explicit-model bypass, and dedicated routing CI shards remain deferred. Unit/component routing tests do not establish live routing behavior. |
| Full allow/deny tool-permission matrix | Not covered; onboarding/trust uses actual TUI choices |
| Desktop Codex app, Isaac itself, auto-upgrades | Not covered by command forwarding or pinned-version tests |
| Native macOS/Windows managed settings, resize/signals | Separate platform coverage needed |
Expand Down
25 changes: 15 additions & 10 deletions tests/integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ test_ug_codex_headless.py # script prompts and model arguments
test_ug_claude_commands.py # command help forwarding
test_ug_codex_commands.py # command help and parser error forwarding
test_ug_codex_app_server.py # actual client/server initialize exchange
test_ug_smart_routing_hooks.py # route-subagent hook contract against the live router
test_ug_configure_claude_lifecycle.py # repeat setup, revert, rejected credentials
test_ug_configure_codex_lifecycle.py # repeat setup, revert, rejected credentials
test_ug_claude_managed_model_discovery.py # fetched/reused Claude MPS policy cases
Expand Down Expand Up @@ -134,9 +135,13 @@ A fixture file contains an unpredictable value absent from the prompt. Success
requires an assistant answer in the real agent transcript containing that value,
plus normal TUI exit. Codex evidence requires its task-complete event.
Interactive first-prompt routing is covered by the managed_fixture smart-routing
banner journeys below for both agents. Subagent routing, interactive explicit-model
bypass, and dedicated smart-routing CI shards remain deferred; unit/component
routing tests do not establish that live behavior.
banner journeys below for both agents. Subagent routing is covered at the hook protocol
level by the route-subagent hook journeys, which drive the real installed hook commands
with a harness-shaped payload against the live router; the subagent-only launch journeys
assert the first-prompt banner and routing wrappers stay silent while the routing hooks
arm. The agent's interactive spawn decision, interactive explicit-model bypass, and
dedicated smart-routing CI shards remain deferred; unit/component routing tests do not
establish that live behavior.

The relayed CUJ launches Claude through a relayed (subscription-relay) MPS and
completes a file task on two models: a bare Anthropic id the subscription serves
Expand All @@ -159,13 +164,13 @@ service allows a different model. Those choices are recorded in `versions.json`.
No service is created or modified. A missing service, permission, or OAuth token
fails the selected CUJ, rather than skipping it.

There are **42 live cases** (including 6 TUI journeys) and **5 installation
There are **46 live cases** (including 6 TUI journeys) and **5 installation
checks** with both agents. A separate **4 managed-workspace cases** (one per agent, an idempotent
re-configure, and a cache-TTL journey; marker `managed`) run against a workspace that publishes a
CodingAgentConfig; see "Managed-workspace journeys" below. A further **34 `managed_fixture`
CodingAgentConfig; see "Managed-workspace journeys" below. A further **36 `managed_fixture`
cases** use `UCODE_MANAGED_CONFIG_STUB`. Twenty-four explicit configured/fresh Claude and Codex
discovery and source-override journeys fetch the published config once per agent, replace that
agent's static source with its dedicated MPS, and reuse the result. Ten existing collected cases
agent's static source with its dedicated MPS, and reuse the result. Twelve existing collected cases
cover focused model, MCP, skills, and lifecycle shapes, including per-agent model reconciliation
and managed skill cleanup.
See the named coverage and gaps matrix in
Expand Down Expand Up @@ -246,13 +251,13 @@ record a discovered `system.ai` model as a test argument.
Every same-repository PR and push to `main` runs **Smoke journeys**, followed by
**Full journeys** even if smoke fails. Smoke runs the Hosted configure/TUI,
headless argument, and custom OAuth CLI TUI journeys for each agent (six cases,
two agent jobs). Full runs all 42 live cases, including those smoke cases, in two
two agent jobs). Full runs all 46 live cases, including those smoke cases, in two
disjoint agent lanes:

| Agent lane | Marker | Cases |
| --- | --- | --- |
| Claude | `live and claude` | 17 |
| Codex | `live and codex` | 25 |
| Claude | `live and claude` | 19 |
| Codex | `live and codex` | 27 |

Each lane installs only its agent CLI, once, and runs all its configure, headless,
commands, lifecycle, and applicable app-server journeys. Cases remain serial
Expand Down Expand Up @@ -504,7 +509,7 @@ uv run --no-project --python 3.12 python scripts/run_integration.py \
unset DATABRICKS_BEARER
```

This runs all 42 live cases. For the five installation checks, run the same
This runs all 46 live cases. For the five installation checks, run the same
runner/version/index arguments with `--installation-only` and omit `-- -m live`;
no bearer or workspace is needed. Results remain under `.integration-runs/`.
Each invocation needs a new output directory; an existing one is rejected.
Expand Down
Loading
Loading