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
6 changes: 5 additions & 1 deletion src/ucode/smart_routing/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from __future__ import annotations

import json
import logging
import os
import re
import textwrap
Expand Down Expand Up @@ -309,8 +310,11 @@ def resolve_spawn_route(
),
default_task_label,
)
decision, _ = decision_fn(task)
decision, reason = decision_fn(task)
if decision is None:
logging.getLogger(__name__).warning(
"Smart Router could not route subagent: %s", reason or "no routing decision"
)
return None
routed_model = model_id_mapper(decision.model)
return SpawnRoute(tool_input, task, decision, routed_model)
Expand Down
24 changes: 24 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -741,3 +741,27 @@ Codex retains its CLI control-file checks. No extra TUI sessions or tasks are ad
Clear/reload, compaction, and denied-write rollback are not independently checked.
A native process check runs the real disabled routing CLI from Claude with inherited
launch flags; it makes no gateway request.

Subagent-routing failures have shared warning coverage in `test_codex_routing.py`;
the live hook journeys retain their routing assertions and include stderr when no
decision is returned. This diagnostic coverage does not prove routing service health.

Smart-routing scenarios use checked-in model menus assigned to the explicit CI
workspace. US `task_v3` uses GPT-6 Sol / Luna; the Canadian managed workspace uses
GPT-5.6 Sol / Terra / Luna, since it has no GPT-6 services. Unknown Codex workspaces
fail until a fixture is assigned. Claude uses Opus 4.8 / Sonnet 4.6 / Haiku 4.5.
Direct hook inputs share the US fixture; managed TUI inputs use their workspace's
fixture. No workspace is auto-selected or modified. All real routing, native notice,
audit correlation, and child completion assertions remain required.

Routing-toggle journeys wait for the child's own persisted assistant answer, in addition
to the parent answer. A parent reporting the expected value while the child is still
running cannot satisfy child completion. The wait is bounded and still fails on timeout.

Codex routing-display unit coverage accepts a generic child label only after the tagged
prompt and before the next prompt, requires the expected orchestration state, and
rejects stale or unrelated panels. Live audit correlation and child answers remain required.

After Codex compaction removes a prompt, display correlation may instead use the
native Spawned row immediately following the routing box. Unit checks reject later
answer text, intervening prompts, and nonadjacent or unrelated spawn rows.
8 changes: 4 additions & 4 deletions tests/fixtures/managed_config/claude_smart_routing.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
"config": {
"models": {
"model_services": [
"system.ai.claude-sonnet-5",
"system.ai.claude-haiku-4-5",
"system.ai.claude-opus-4-8"
"system.ai.claude-opus-4-8",
"system.ai.claude-sonnet-4-6",
"system.ai.claude-haiku-4-5"
]
},
"default_models": {
"default_model": "system.ai.claude-sonnet-5"
"default_model": "system.ai.claude-sonnet-4-6"
},
"smart_routing": {
"enabled": true
Expand Down
7 changes: 3 additions & 4 deletions tests/fixtures/managed_config/codex_smart_routing.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@
"config": {
"models": {
"model_services": [
"system.ai.gpt-5-6-sol",
"system.ai.gpt-5-6-terra",
"system.ai.gpt-5-6-luna"
"system.ai.gpt-6-sol",
"system.ai.gpt-6-luna"
]
},
"default_models": {
"default_model": "system.ai.gpt-5-6-sol"
"default_model": "system.ai.gpt-6-sol"
},
"smart_routing": {
"enabled": true
Expand Down
24 changes: 24 additions & 0 deletions tests/fixtures/managed_config/codex_smart_routing_ca.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"spec_version": 1,
"default_agent": "CODING_AGENT_CODEX",
"enabled_agents": [
{
"agent": "CODING_AGENT_CODEX",
"config": {
"models": {
"model_services": [
"system.ai.gpt-5-6-sol",
"system.ai.gpt-5-6-terra",
"system.ai.gpt-5-6-luna"
]
},
"default_models": {
"default_model": "system.ai.gpt-5-6-sol"
},
"smart_routing": {
"enabled": true
}
}
}
]
}
29 changes: 29 additions & 0 deletions tests/integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1248,3 +1248,32 @@ recipe change does not require editing the test.
Local Claude mod confirmation replaces the old saved-control-file/assistant-turn wait;
Codex retains its CLI control-file checks. No extra TUI sessions or tasks are added.
Clear/reload, compaction, and denied-write rollback are not independently checked.

Subagent-routing failures have shared warning coverage in `test_codex_routing.py`;
the live hook journeys retain their routing assertions and include stderr when no
decision is returned. This diagnostic coverage does not prove routing service health.

Smart-routing scenarios use checked-in model menus assigned to the explicit CI
workspace. US `task_v3` uses GPT-6 Sol / Luna; the Canadian managed workspace uses
GPT-5.6 Sol / Terra / Luna, since it has no GPT-6 services. Unknown Codex workspaces
fail until a fixture is assigned. Claude uses Opus 4.8 / Sonnet 4.6 / Haiku 4.5.
Direct hook inputs share the US fixture; managed TUI inputs use their workspace's
fixture. No workspace is auto-selected or modified. All real routing, native notice,
audit correlation, and child completion assertions remain required.

Routing-toggle journeys wait for the child's own persisted assistant answer, in addition
to the parent answer. A parent reporting the expected value while the child is still
running cannot satisfy child completion. The wait is bounded and still fails on timeout.

Codex routing-display unit coverage accepts a generic child label only after the tagged
prompt and before the next prompt, requires the expected orchestration state, and
rejects stale or unrelated panels. Live audit correlation and child answers remain required.

After Codex compaction removes a prompt, display correlation may instead use the
native Spawned row immediately following the routing box. Unit checks reject later
answer text, intervening prompts, and nonadjacent or unrelated spawn rows.

For a managed workspace other than the two CI workspaces, set
`UCODE_TEST_CODEX_ROUTING_FIXTURE` to the basename of a checked-in fixture whose model menu has
been validated in that workspace. The suite never guesses a model menu or silently changes the
workspace.
6 changes: 3 additions & 3 deletions tests/integration/test_ug_configure_managed_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import pytest
from utils.constants import MANAGED_CLAUDE_PROVIDER_SERVICE
from utils.evidence import FileTask
from utils.managed import use_managed_config_fixture
from utils.managed import use_managed_config_fixture, use_smart_routing_fixture
from utils.provider_catalog import fetch_anthropic_parent_catalog, fetch_anthropic_provider_catalog
from utils.terminal import AgentTerminal, TerminalProcess

Expand Down Expand Up @@ -245,7 +245,7 @@ def test_managed_fixture_claude_smart_routing_banner(
"""
session = live_session
task = FileTask(session)
use_managed_config_fixture(session, "claude_smart_routing")
use_smart_routing_fixture(session, "claude", workspace)
result = session.run("configure", "--workspace", workspace, "--skip-upgrade", timeout=240)
assert "Select coding agents to configure:" not in result.stdout, result.stdout
if SMART_ROUTER_CONFIG_VERSION is not None:
Expand Down Expand Up @@ -291,7 +291,7 @@ def test_managed_fixture_codex_smart_routing_banner(
"""
session = live_session
task = FileTask(session)
use_managed_config_fixture(session, "codex_smart_routing")
use_smart_routing_fixture(session, "codex", workspace)
result = session.run("configure", "--workspace", workspace, "--skip-upgrade", timeout=240)
assert "Select coding agents to configure:" not in result.stdout, result.stdout
if SMART_ROUTER_CONFIG_VERSION is not None:
Expand Down
71 changes: 27 additions & 44 deletions tests/integration/test_ug_smart_routing_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,45 +17,32 @@
agent_sessions,
assert_subagent_routed,
assistant_answers,
codex_routing_display_for_task,
is_child_session,
read_jsonl,
tool_outputs,
)
from utils.managed import use_managed_config_fixture
from utils.managed import MANAGED_CONFIG_FIXTURES, use_smart_routing_fixture
from utils.request_recorder import TuiRequestRecorder, served_inference_request
from utils.terminal import AgentTerminal

pytestmark = pytest.mark.harness_latest

SMART_ROUTING_BANNER = "Using Unity Gateway Smart Router."
SMART_ROUTING_SUBAGENT_NOTICE = "Using Unity Gateway Smart Router - Subagent"
CLAUDE_MODELS = [
"system.ai.claude-opus-5",
"system.ai.claude-sonnet-5",
"system.ai.claude-haiku-4-5",
"system.ai.glm-5-3",
"system.ai.kimi-k3",
]
CODEX_MODELS = [
"system.ai.gpt-6-astra",
"system.ai.gpt-5-6-sol",
"system.ai.gpt-5-6-terra",
"system.ai.gpt-5-6-luna",
"system.ai.gpt-5-5",
"system.ai.glm-5-3",
"system.ai.kimi-k3",
]
# Codex launches subagents on its bundled catalog slugs, not the workspace model id, so
# the routed model in the hook response must be one of these slugs.
CODEX_MODEL_SLUGS = {
"gpt-6-astra",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
"glm-5-3",
"kimi-k3",
}


# Use the same task_v3-compatible menu for direct hooks and managed TUI journeys.
# Direct hooks run in the US workspace; managed TUI inputs are assigned by workspace.
def _fixture_routing_models(agent: str) -> list[str]:
config = json.loads((MANAGED_CONFIG_FIXTURES / f"{agent}_smart_routing.json").read_text())
return config["enabled_agents"][0]["config"]["models"]["model_services"]


CLAUDE_MODELS = _fixture_routing_models("claude")
CODEX_MODELS = _fixture_routing_models("codex")
# Expected native Codex slugs, independently of the application's model mapper.
CODEX_MODEL_SLUGS = {"gpt-6-sol", "gpt-6-luna"}
SKILL_ROOTS = {"claude": ".claude/skills", "codex": ".codex/skills"}


Expand Down Expand Up @@ -91,20 +78,7 @@ def _routing_display_for_task(
lines = screen.splitlines()
if agent == "claude":
return _claude_routing_row_for_task(lines, marker)
for index, line in enumerate(lines):
if SMART_ROUTING_SUBAGENT_NOTICE not in line:
continue
panel = []
for panel_line in lines[index : index + 12]:
panel.append(panel_line)
if "└" in panel_line:
break
# Rich can wrap the marker between any two characters in a narrow TUI.
# Compare without rendered whitespace so the banner remains attributable.
text = "".join("\n".join(panel).split())
if marker in text and (not orchestrator_on or "[orchestratoron]" in text):
return True
return False
return codex_routing_display_for_task(screen, marker, orchestrator_on=orchestrator_on)


def _run_calculation(
Expand Down Expand Up @@ -133,6 +107,13 @@ def _run_calculation(
timeout=120,
)
tui.wait_for_task(task, timeout=180)
# A parent may report the requested value while its background child is still
# running. Require the child's own persisted answer before asserting completion.
tui.wait_for(
lambda _: task.completed(session, agent, child=True),
f"the child assistant answer for {task.marker}",
timeout=180,
)
task.assert_completed(session, agent)
task.assert_completed(session, agent, child=True)

Expand Down Expand Up @@ -302,6 +283,7 @@ def test_smart_routing_claude_route_subagent_hook(
input_text=json.dumps(payload),
timeout=60,
)
assert result.stdout.strip(), f"Routing hook returned no decision: {result.stderr}"
output = json.loads(result.stdout)
assert output["model"] in CLAUDE_MODELS, output
assert isinstance(output["rationale"], str), output
Expand Down Expand Up @@ -379,6 +361,7 @@ def test_smart_routing_codex_route_subagent_hook(
input_text=json.dumps(payload),
timeout=60,
)
assert result.stdout.strip(), f"Routing hook returned no decision: {result.stderr}"
output = json.loads(result.stdout)
hook = output["hookSpecificOutput"]
assert hook["hookEventName"] == "PreToolUse", output
Expand Down Expand Up @@ -446,7 +429,7 @@ def test_smart_router_skill_toggles_claude_subagent_routing(
session.env["ENABLE_SMART_ROUTER_ORCHESTRATOR"] = "1"
else:
session.env["SMART_ROUTER_CONFIG_VERSION"] = SMART_ROUTER_CONFIG_VERSION
use_managed_config_fixture(session, "claude_smart_routing")
use_smart_routing_fixture(session, "claude", workspace)
with TuiRequestRecorder(workspace) as recorder:
# Configure through the forwarding URL using the public CLI, keeping ug state real.
session.run(
Expand Down Expand Up @@ -581,7 +564,7 @@ def test_smart_router_skill_toggles_codex_subagent_routing(
session.env["ENABLE_SMART_ROUTER_ORCHESTRATOR"] = "1"
else:
session.env["SMART_ROUTER_CONFIG_VERSION"] = SMART_ROUTER_CONFIG_VERSION
use_managed_config_fixture(session, "codex_smart_routing")
use_smart_routing_fixture(session, "codex", workspace)
session.run(
"configure",
"--workspace",
Expand Down
42 changes: 42 additions & 0 deletions tests/integration/utils/evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,3 +361,45 @@ def assert_subagent_routed(
# Some agent versions omit the child's model from SubagentStart. The report
# preserves that unknown value; this test claims decision + spawn + task,
# not model-identity verification when the agent did not expose it.


def codex_routing_display_for_task(screen: str, marker: str, *, orchestrator_on: bool) -> bool:
"""Match a routing box to its tagged prompt or immediately following native spawn.

Compaction can remove the prompt; the adjacent Spawned row still identifies
the child. Live audit and child-answer assertions verify the actual decision.
"""
lines = screen.splitlines()
prompts = [index for index, line in enumerate(lines) if line.lstrip().startswith("›")]
for index, line in enumerate(lines):
if "Using Unity Gateway Smart Router - Subagent" not in line:
continue
end = index + 1
while end < min(index + 12, len(lines)) and "└" not in lines[end]:
end += 1
if end >= len(lines) or "└" not in lines[end]:
continue
text = "".join("".join(lines[index : end + 1]).split())
if "SelectedModel:" not in text or ("[orchestratoron]" in text) != orchestrator_on:
continue
preceding = [start for start in prompts if start < index]
if preceding:
start = preceding[-1]
prompt_end = start + 1
while prompt_end < index and lines[prompt_end].strip():
prompt_end += 1
if marker in "".join("".join(lines[start:prompt_end]).split()):
return True
# A compacted prompt is unavailable, but its native spawn remains directly
# after the routing box. Do not accept later echoed answers or tool results.
spawn = end + 1
while spawn < len(lines) and not lines[spawn].strip():
spawn += 1
if spawn >= len(lines) or not lines[spawn].lstrip().startswith("• Spawned "):
continue
spawn_end = spawn + 1
while spawn_end < len(lines) and lines[spawn_end].strip():
spawn_end += 1
if marker in "".join("".join(lines[spawn:spawn_end]).split()):
return True
return False
27 changes: 27 additions & 0 deletions tests/integration/utils/managed.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
enforces that boundary).
"""

import os
from pathlib import Path
from urllib.parse import urlparse

MANAGED_CONFIGS_PATH = "/api/ai-gateway/v2/coding-agent-configs"
# One JSON CodingAgentConfig per scenario, in the GET shape `ug configure --file` will accept.
Expand Down Expand Up @@ -38,3 +40,28 @@ def assert_no_managed_config(payload: object) -> None:
def is_managed_config_control_plane_cache(home: Path, path: Path) -> bool:
"""Whether ``path`` is ug's expected fetched-config cache, not agent-owned state."""
return path == home / ".ucode" / "managed-config.json"


def use_smart_routing_fixture(session, agent: str, workspace: str) -> Path:
"""Select the checked-in scenario input for the two configured CI workspaces.

The regions publish different GPT generations. This chooses no workspace and
retries no request; unsupported workspaces need an explicit fixture assignment.
"""
if agent == "claude":
name = "claude_smart_routing"
elif agent == "codex":
override = os.environ.get("UCODE_TEST_CODEX_ROUTING_FIXTURE", "").strip()
fixtures = {
"eng-ml-inference-team-us-east-1.cloud.databricks.com": "codex_smart_routing",
"eng-ml-inference-team-ca-central-1.cloud.databricks.com": "codex_smart_routing_ca",
}
hostname = urlparse(workspace).hostname
name = override or fixtures.get(hostname)
assert name, (
f"No Codex routing fixture assigned to {hostname}; set "
"UCODE_TEST_CODEX_ROUTING_FIXTURE to a fixture basename validated for that workspace"
)
else:
raise AssertionError(f"No routing fixture for {agent}")
return use_managed_config_fixture(session, name)
Loading
Loading