From 9d8406959fd031db63f532c0c16431d1b1271abb Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Sat, 3 Oct 2026 17:16:14 +0000 Subject: [PATCH 01/18] Add ENABLE_CLAUDE_CODE_MODS flag and truthy env reader Introduce the ENABLE_CLAUDE_CODE_MODS environment variable, a shared TRUTHY_ENV_VALUES set ("1"/"true", case-insensitive), and a general claude_code_mods_enabled() reader in constants. Harness-general and not tied to smart routing; nothing is gated on it in this change. Co-authored-by: Isaac --- src/ucode/constants.py | 15 +++++++++++++++ tests/test_agent_claude.py | 12 +++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/ucode/constants.py b/src/ucode/constants.py index 1db9930e6..081dac8ac 100644 --- a/src/ucode/constants.py +++ b/src/ucode/constants.py @@ -1,8 +1,14 @@ """Shared UCode constants.""" +import os +from collections.abc import Mapping + LOCALHOST = "localhost" LOOPBACK_HOST = "127.0.0.1" +# Values an on/off env var may use to mean "on" (compare after .strip().lower()). +TRUTHY_ENV_VALUES = frozenset({"1", "true"}) + ENABLE_SMART_ROUTING_ENV_VAR = "ENABLE_SMART_ROUTING_V2" ENABLE_SUBAGENT_ROUTING_ENV_VAR = "ENABLE_SMART_ROUTING_SUBAGENT_ONLY" SMART_ROUTING_ENV_KEYS = ( @@ -10,6 +16,15 @@ ENABLE_SUBAGENT_ROUTING_ENV_VAR, ) +ENABLE_CLAUDE_CODE_MODS_ENV_VAR = "ENABLE_CLAUDE_CODE_MODS" + + +def claude_code_mods_enabled(env: Mapping[str, str] | None = None) -> bool: + """Whether the Claude Code mods experience is enabled.""" + source = os.environ if env is None else env + return source.get(ENABLE_CLAUDE_CODE_MODS_ENV_VAR, "").strip().lower() in TRUTHY_ENV_VALUES + + MODEL_PROVIDER_SERVICE_HEADER = "Databricks-Model-Provider-Service" MODEL_SERVICE_PARENT_SCHEMA_HEADER = "Databricks-Model-Service-Parent-Schema" # Names the smart-router recipe (e.g. `task_v3`) in use; sent only when smart routing is enabled. diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index 148543d1e..4f1fc62a3 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -12,8 +12,8 @@ import pytest +from ucode import constants, managed_files from ucode import databricks as db_mod -from ucode import managed_files from ucode.agents import LaunchOptions, claude from ucode.smart_routing import claude_routing, v2 from ucode.state import MANAGED_OVERLAY_KEY @@ -83,6 +83,16 @@ def test_unknown_version_does_not_block(self, monkeypatch): assert claude.minimum_version_error() is None +class TestClaudeCodeModsFlag: + def test_enabled_for_one_or_true_case_insensitively(self): + assert constants.claude_code_mods_enabled({"ENABLE_CLAUDE_CODE_MODS": "1"}) + assert constants.claude_code_mods_enabled({"ENABLE_CLAUDE_CODE_MODS": "true"}) + assert constants.claude_code_mods_enabled({"ENABLE_CLAUDE_CODE_MODS": "TRUE"}) + assert not constants.claude_code_mods_enabled({"ENABLE_CLAUDE_CODE_MODS": "yes"}) + assert not constants.claude_code_mods_enabled({"ENABLE_CLAUDE_CODE_MODS": "0"}) + assert not constants.claude_code_mods_enabled({}) + + class TestRenderOverlay: def test_long_context_suffix_supports_major_only_claude_versions(self): assert claude._maybe_add_1m_suffix("system.ai.claude-sonnet-5") == ( From 0bb26617d25418e4a294108bf1d1d6f22fa769ca Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Sat, 3 Oct 2026 17:30:20 +0000 Subject: [PATCH 02/18] Add reusable Claude Mods infra; smart-routing status band Introduce ucode.mods: a reusable home for shipping Claude Code mods outside smart routing. Each mod's source is real TypeScript under typescript/claude-mods/ (shipped with the wheel). Claude Code loads one hooks module per plugin, so a mod is a register.ts entry that imports its concern modules; a ClaudeMod( source="register.ts", extra=(...)) plus write_mod(plugin_dir, mod) copies the entry and the files it imports into a launch-scoped plugin's hooks/. The mods-wide Claude version floor (2.1.287) lives here too. The first mod draws a band above the prompt (AbovePrompt) via register.ts -> smart-routing-status.ts, showing whether smart routing is on and which prompts it routes; the "on" pill uses a bright background with black text so it reads in both light and dark terminals. Visualization only; ug's Python hooks still route, and state is read via the mods API (env + the live session-env file), mirroring v2.smart_routing_enabled / first_prompt_routing_enabled and accepting "true" as well as "1". Co-authored-by: Isaac --- pyproject.toml | 1 + src/ucode/agents/claude.py | 17 +++- src/ucode/mods.py | 67 ++++++++++++++++ src/ucode/smart_routing/v2.py | 30 +++++-- tests/test_agent_claude.py | 21 +++++ tests/test_claude_smart_routing_v2.py | 33 ++++++++ typescript/claude-mods/register.ts | 13 +++ .../claude-mods/smart-routing-status.ts | 79 +++++++++++++++++++ 8 files changed, 250 insertions(+), 11 deletions(-) create mode 100644 src/ucode/mods.py create mode 100644 typescript/claude-mods/register.ts create mode 100644 typescript/claude-mods/smart-routing-status.ts diff --git a/pyproject.toml b/pyproject.toml index 39fbd9cf9..cebbf990a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ packages = ["src/ucode"] [tool.hatch.build.targets.wheel.force-include] "skills" = "skills" +"typescript" = "typescript" [tool.hatch.version] source = "uv-dynamic-versioning" diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index 83910d65d..5d824195e 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -16,7 +16,7 @@ from collections.abc import Callable from pathlib import Path -from ucode import gateway_proxy +from ucode import gateway_proxy, mods from ucode.config_io import ( APP_DIR, ToolSpec, @@ -27,12 +27,14 @@ write_json_file, ) from ucode.constants import ( + ENABLE_CLAUDE_CODE_MODS_ENV_VAR, LOOPBACK_HOST, MCP_CLEANUP_SCOPES, MCP_USER_SCOPE, MODEL_PROVIDER_SERVICE_HEADER, MODEL_SERVICE_PARENT_SCHEMA_HEADER, SMART_ROUTER_RECIPE_HEADER, + claude_code_mods_enabled, ) from ucode.custom_oauth import ( CustomOAuthConfig, @@ -131,10 +133,19 @@ def _parse_version(value: str) -> tuple[int, int, int] | None: def minimum_version_error() -> str | None: version = agent_version(SPEC["binary"]) parsed = _parse_version(version) - if parsed is None or parsed >= MINIMUM_CLAUDE_VERSION: + if parsed is None: + return None + floor = MINIMUM_CLAUDE_VERSION + floor_text = MINIMUM_CLAUDE_VERSION_TEXT + suffix = "" + if claude_code_mods_enabled() and parsed < mods.MINIMUM_CLAUDE_VERSION_FOR_MODS: + floor = mods.MINIMUM_CLAUDE_VERSION_FOR_MODS + floor_text = mods.MINIMUM_CLAUDE_VERSION_FOR_MODS_TEXT + suffix = f" when {ENABLE_CLAUDE_CODE_MODS_ENV_VAR} is set" + if parsed >= floor: return None return ( - f"ug requires Claude Code {MINIMUM_CLAUDE_VERSION_TEXT} or newer. " + f"ug requires Claude Code {floor_text} or newer{suffix}. " f"Your current version is Claude Code {version}." ) diff --git a/src/ucode/mods.py b/src/ucode/mods.py new file mode 100644 index 000000000..b0f4a6d8e --- /dev/null +++ b/src/ucode/mods.py @@ -0,0 +1,67 @@ +"""Shared infrastructure for shipping Claude Code "mods". + +A Claude Mod (Claude Code >= 2.1.287) is a plugin whose ``hooks/hooks.json`` +lists a ``modules`` entry pointing at an in-process TypeScript module that draws +in the TUI. ug keeps each mod's source as a real ``.ts`` file under +``typescript/claude-mods/`` at the repo root (shipped with the package) and +copies it into a launch-scoped plugin's ``hooks/`` on demand. + +Adding a mod going forward: + 1. Drop ``typescript/claude-mods/.ts`` (exporting ``register``). + 2. Register it here: ``MY_MOD = ClaudeMod(source=".ts")``. + 3. Write it into a plugin dir with ``write_mod(plugin_dir, MY_MOD)``. + +Gating lives in :mod:`ucode.constants` (``ENABLE_CLAUDE_CODE_MODS`` / +``claude_code_mods_enabled``); the Claude version floor is here since it is a +mods-wide requirement, not a smart-routing one. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from importlib.metadata import distribution +from pathlib import Path + +from ucode.config_io import write_json_file, write_text_file + +# Claude Mods (hooks modules in installed plugins) landed in Claude Code 2.1.287. +MINIMUM_CLAUDE_VERSION_FOR_MODS = (2, 1, 287) +MINIMUM_CLAUDE_VERSION_FOR_MODS_TEXT = "2.1.287" + +_SOURCE_SUBDIR = "claude-mods" + + +@dataclass(frozen=True) +class ClaudeMod: + """A mod ug can write into a launch-scoped plugin. + + ``source`` is the entry ``.ts`` under ``typescript/claude-mods/`` (also the + hooks module filename). ``extra`` names sibling files it imports (e.g. a + shared ``pets.ts``); they are copied alongside so relative imports resolve. + """ + + source: str + extra: tuple[str, ...] = () + + +def _mods_source_dir() -> Path: + # Prefer the live repo tree in a dev/editable checkout (so edits to the TS + # take effect without a rebuild); fall back to the copy bundled in the wheel. + repo = Path(__file__).resolve().parents[2] / "typescript" / _SOURCE_SUBDIR + if repo.is_dir(): + return repo + return Path(str(distribution("unity-gateway").locate_file("typescript"))) / _SOURCE_SUBDIR + + +def write_mod(plugin_dir: Path, mod: ClaudeMod) -> None: + """Copy ``mod``'s TypeScript (entry + any siblings) into ``plugin_dir``'s hooks.""" + source_dir = _mods_source_dir() + hooks_dir = plugin_dir / "hooks" + for name in (mod.source, *mod.extra): + write_text_file(hooks_dir / name, (source_dir / name).read_text(encoding="utf-8")) + write_json_file(hooks_dir / "hooks.json", {"modules": [f"./{mod.source}"]}) + + +# ug's smart-routing UI mod: a register.ts entry that composes the concern +# modules it imports (today the status band). +SMART_ROUTING_UI = ClaudeMod(source="register.ts", extra=("smart-routing-status.ts",)) diff --git a/src/ucode/smart_routing/v2.py b/src/ucode/smart_routing/v2.py index 50790dfac..3a9249992 100644 --- a/src/ucode/smart_routing/v2.py +++ b/src/ucode/smart_routing/v2.py @@ -14,7 +14,7 @@ from tempfile import TemporaryDirectory from typing import NoReturn, TextIO -from ucode import config_io +from ucode import config_io, mods from ucode.codex_config import ( codex_config_args, custom_catalog_models, @@ -32,6 +32,8 @@ ENABLE_SUBAGENT_ROUTING_ENV_VAR, LOOPBACK_HOST, SMART_ROUTING_ENV_KEYS, + TRUTHY_ENV_VALUES, + claude_code_mods_enabled, ) from ucode.custom_oauth import custom_oauth_cli_enabled, get_custom_client_token from ucode.databricks import ( @@ -145,12 +147,16 @@ def _model_picker_catalog() -> AnthropicModelCatalog | None: return None +def _env_truthy(value: str | None) -> bool: + return isinstance(value, str) and value.strip().lower() in TRUTHY_ENV_VALUES + + def smart_routing_enabled( env: MutableMapping[str, str] | None = None, *, default: bool = False ) -> bool: source = os.environ if env is None else env values = [source.get(var) for var in SMART_ROUTING_ENV_KEYS] - if "1" in values: + if any(_env_truthy(value) for value in values): return True if "0" in values: return False @@ -160,9 +166,8 @@ def smart_routing_enabled( 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" + return _env_truthy(source.get(ENABLE_SMART_ROUTING_ENV_VAR)) and not _env_truthy( + source.get(ENABLE_SUBAGENT_ROUTING_ENV_VAR) ) @@ -304,8 +309,13 @@ def _routed_claude_agent_definitions(model_ids: list[str]) -> dict[str, dict[str } -def _write_routed_claude_plugin(plugin_dir: Path, model_ids: list[str]) -> None: - """Write exact-model agents for launch-scoped loading through --plugin-dir.""" +def _write_routed_claude_plugin( + plugin_dir: Path, model_ids: list[str], *, include_mod: bool = False +) -> None: + """Write exact-model agents for launch-scoped loading through --plugin-dir. + + With ``include_mod``, the smart-routing Claude Mod is written into the same plugin. + """ write_json_file( plugin_dir / ".claude-plugin" / "plugin.json", { @@ -315,6 +325,8 @@ def _write_routed_claude_plugin(plugin_dir: Path, model_ids: list[str]) -> None: "author": {"name": "Databricks"}, }, ) + if include_mod: + mods.write_mod(plugin_dir, mods.SMART_ROUTING_UI) for name, definition in _routed_claude_agent_definitions(model_ids).items(): slug = name.partition(":")[2] write_text_file( @@ -562,7 +574,9 @@ def route_prompt(prompt: str) -> claude_pty.FirstPromptRoute: env[SESSION_PYTHON_ENV_VAR] = os.environ[SESSION_PYTHON_ENV_VAR] try: write_json_file(settings_path, settings) - _write_routed_claude_plugin(plugin_dir, model_ids) + _write_routed_claude_plugin( + plugin_dir, model_ids, include_mod=claude_code_mods_enabled() + ) except Exception as exc: # noqa: BLE001 - optional setup must not block normal launch raise ClaudeRoutingSetupError("Failed to write Claude smart-routing files") from exc model_args = launch_model_args(remaining, launch_model) diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index 4f1fc62a3..367b9daa3 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -82,6 +82,27 @@ def test_unknown_version_does_not_block(self, monkeypatch): assert claude.minimum_version_error() is None + def test_mods_do_not_raise_floor_when_flag_unset(self, monkeypatch): + monkeypatch.delenv("ENABLE_CLAUDE_CODE_MODS", raising=False) + monkeypatch.setattr(claude, "agent_version", lambda _binary: "2.1.260") + + assert claude.minimum_version_error() is None + + def test_mods_flag_requires_mods_version(self, monkeypatch): + monkeypatch.setenv("ENABLE_CLAUDE_CODE_MODS", "1") + monkeypatch.setattr(claude, "agent_version", lambda _binary: "2.1.286") + + assert claude.minimum_version_error() == ( + "ug requires Claude Code 2.1.287 or newer when ENABLE_CLAUDE_CODE_MODS is set. " + "Your current version is Claude Code 2.1.286." + ) + + def test_mods_flag_satisfied_by_mods_version(self, monkeypatch): + monkeypatch.setenv("ENABLE_CLAUDE_CODE_MODS", "1") + monkeypatch.setattr(claude, "agent_version", lambda _binary: "2.1.287") + + assert claude.minimum_version_error() is None + class TestClaudeCodeModsFlag: def test_enabled_for_one_or_true_case_insensitively(self): diff --git a/tests/test_claude_smart_routing_v2.py b/tests/test_claude_smart_routing_v2.py index 6179e3a75..f8227c07f 100644 --- a/tests/test_claude_smart_routing_v2.py +++ b/tests/test_claude_smart_routing_v2.py @@ -12,6 +12,7 @@ import pytest +from ucode import mods from ucode.agents import LaunchOptions, claude from ucode.databricks import AnthropicModelCatalog from ucode.smart_routing import claude_hooks, claude_pty, routing, v2 @@ -204,6 +205,15 @@ def test_subagent_only_flag_suppresses_first_prompt_routing(self, monkeypatch): monkeypatch.delenv(v2.ENABLE_SUBAGENT_ROUTING_ENV_VAR) assert v2.first_prompt_routing_enabled() + def test_smart_routing_vars_accept_true_as_well_as_one(self): + v2_var = v2.ENABLE_SMART_ROUTING_ENV_VAR + sub_var = v2.ENABLE_SUBAGENT_ROUTING_ENV_VAR + assert v2.smart_routing_enabled({v2_var: "true"}) + assert v2.smart_routing_enabled({sub_var: "TRUE"}) + assert not v2.smart_routing_enabled({v2_var: "yes"}) + assert v2.first_prompt_routing_enabled({v2_var: "true"}) + assert not v2.first_prompt_routing_enabled({v2_var: "true", sub_var: "True"}) + class TestV2Launch: def test_strips_gateway_prefix_for_interposer(self): @@ -680,6 +690,7 @@ def test_writes_routed_agents_as_launch_scoped_plugin(self, tmp_path): v2._write_routed_claude_plugin(plugin_dir, models) + assert not (plugin_dir / "hooks").exists() manifest = json.loads((plugin_dir / ".claude-plugin" / "plugin.json").read_text()) assert manifest["name"] == "ug-smart-router" assert _plugin_agent_models(plugin_dir) == { @@ -693,6 +704,28 @@ def test_writes_routed_agents_as_launch_scoped_plugin(self, tmp_path): assert v2.CLAUDE_ROUTED_AGENT_PROMPT in agent assert v2._routed_claude_agent_name(model) == f"{manifest['name']}:{slug}" + def test_includes_mod_when_requested(self, tmp_path): + plugin_dir = tmp_path / "routing-plugin" + + v2._write_routed_claude_plugin(plugin_dir, ["system.ai.claude-opus-4-8"], include_mod=True) + + mod = mods.SMART_ROUTING_UI + hooks_dir = plugin_dir / "hooks" + hooks = json.loads((hooks_dir / "hooks.json").read_text()) + # One hooks module (register.ts) per plugin; the files it imports ride along. + assert hooks == {"modules": ["./register.ts"]} + for name in (mod.source, *mod.extra): + assert (hooks_dir / name).is_file() + assert "registerStatusBand" in (hooks_dir / "register.ts").read_text() + + status = (hooks_dir / "smart-routing-status.ts").read_text() + assert "unity gateway smart router" in status + assert "ui.render" in status and "AbovePrompt" in status + # Env var names stay in sync with the Python source of truth. + assert v2.ENABLE_SMART_ROUTING_ENV_VAR in status + assert v2.ENABLE_SUBAGENT_ROUTING_ENV_VAR in status + assert v2.SESSION_ENV_VAR in status + def test_leaves_non_claude_custom_agent_model_unchanged(self): definitions = v2._routed_claude_agent_definitions(["catalog.schema.gpt-5"]) diff --git a/typescript/claude-mods/register.ts b/typescript/claude-mods/register.ts new file mode 100644 index 000000000..458433917 --- /dev/null +++ b/typescript/claude-mods/register.ts @@ -0,0 +1,13 @@ +import type { Register } from 'claude-code' + +import { registerStatusBand } from './smart-routing-status' + +// Entry for ug's smart-routing UI mod. Claude Code loads one hooks module per +// plugin, so this file imports and composes the concerns; today just the status +// band above the prompt, with more (e.g. the per-subagent routing line) importing +// here. Written into the launch-scoped routing plugin under +// ENABLE_CLAUDE_CODE_MODS (see ucode.mods); visualization only, ug's Python hooks +// still route. +export const register: Register = on => { + registerStatusBand(on) +} diff --git a/typescript/claude-mods/smart-routing-status.ts b/typescript/claude-mods/smart-routing-status.ts new file mode 100644 index 000000000..857ed9041 --- /dev/null +++ b/typescript/claude-mods/smart-routing-status.ts @@ -0,0 +1,79 @@ +import type { Register } from 'claude-code' + +type On = Parameters[0] + +// The status band concern of ug's smart-routing UI mod (composed by register.ts). +// Draws a band above the prompt showing whether smart routing is on and what it +// routes. The "on" pill uses a bright background with black text so it reads in +// both light and dark terminals (Claude Code's theme keys color foreground text, +// not pill backgrounds). The env var names and truthy vocabulary below are kept +// in sync with ucode.constants / ucode.smart_routing.v2 (a test guards it). + +const SMART_ROUTING_KEY = 'ENABLE_SMART_ROUTING_V2' +const SUBAGENT_ONLY_KEY = 'ENABLE_SMART_ROUTING_SUBAGENT_ONLY' +const SESSION_FILE_KEY = 'UCODE_SESSION_ENV_FILE' +const BANNER = 'unity gateway smart router' +const TRUTHY = ['1', 'true'] + +const truthy = (value: unknown): boolean => + typeof value === 'string' && TRUTHY.includes(value.trim().toLowerCase()) + +type RoutingState = { enabled: boolean; firstPrompt: boolean } + +// Mirror ucode.smart_routing.v2.smart_routing_enabled / first_prompt_routing_enabled. +// A hooks module has no Node APIs: env and files come through the mods API, and +// $.env.get must be called with a string literal. +async function readRoutingState($: any): Promise { + let full: unknown + let subagentOnly: unknown + let file: unknown + try { + full = await $.env.get('ENABLE_SMART_ROUTING_V2') + } catch {} + try { + subagentOnly = await $.env.get('ENABLE_SMART_ROUTING_SUBAGENT_ONLY') + } catch {} + try { + file = await $.env.get('UCODE_SESSION_ENV_FILE') + } catch {} + if (typeof file === 'string' && file) { + try { + const overrides = JSON.parse(await $.fs.read(file)) + if (overrides && typeof overrides === 'object') { + if (SMART_ROUTING_KEY in overrides) full = overrides[SMART_ROUTING_KEY] + if (SUBAGENT_ONLY_KEY in overrides) subagentOnly = overrides[SUBAGENT_ONLY_KEY] + } + } catch {} + } + return { + enabled: truthy(full) || truthy(subagentOnly), + firstPrompt: truthy(full) && !truthy(subagentOnly), + } +} + +export const registerStatusBand = (on: On): void => { + on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { + const theirs = await next(e) + const { Box, Text } = $.ui.resolve(e) + const { enabled, firstPrompt } = await readRoutingState($) + + const pill = enabled + ? Text({ bold: true, color: 'black', backgroundColor: 'green', children: [' ON '] }) + : Text({ bold: true, color: 'black', backgroundColor: 'gray', children: [' OFF '] }) + const trailer = enabled + ? Text({ + dimColor: true, + italic: true, + children: ['— ' + (firstPrompt ? 'routing first prompt + subagents' : 'routing subagents')], + }) + : Text({ dimColor: true, children: ["— Reenable with '/smart-router on'"] }) + + const line = Box({ + flexDirection: 'row', + columnGap: 1, + children: [Text({ bold: true, children: [BANNER] }), pill, trailer], + }) + const children = theirs ? [line, theirs] : [line] + return Box({ flexDirection: 'column', children }) + }) +} From 7d23b056d570ca16755f8481064f4ca8c657280d Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Sat, 3 Oct 2026 19:47:11 +0000 Subject: [PATCH 03/18] Render subagent routing as a compact line (mods); silence plain-text notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the subagent-routing concern to the smart-routing UI mod: register.ts now also composes subagent-routing.ts, which renders a compact line under each Agent row showing where it was routed, e.g. ↳ claude-sonnet-5 ●○○ low · an explicit target, localized scope The routed model comes off the Agent row's result (resolvedModel); the tier and reason come from the router rationale ("Routed to Low because …", regex-parsed). Model text uses Claude Code's built-in `primary` theme color. Under ENABLE_CLAUDE_CODE_MODS, the Python subagent hook now: - drops its plain-text ASCII routing notice (no systemMessage / reason) — the mod renders instead — while keeping the model rewrite; - writes the model + rationale, keyed by task, to a per-session mod-routing.json next to the session env file for the mod to read; and - the launch propagates ENABLE_CLAUDE_CODE_MODS into the hook environment and omits the "Routing subagent model" spinner label (the mod is the feedback). Without the flag, the plain-text notice and spinner are unchanged. Co-authored-by: Isaac --- src/ucode/mods.py | 5 +- src/ucode/smart_routing/claude_hooks.py | 16 ++-- src/ucode/smart_routing/v2.py | 52 ++++++++++-- tests/test_claude_smart_routing_v2.py | 64 ++++++++++++++- typescript/claude-mods/register.ts | 11 +-- typescript/claude-mods/subagent-routing.ts | 94 ++++++++++++++++++++++ 6 files changed, 225 insertions(+), 17 deletions(-) create mode 100644 typescript/claude-mods/subagent-routing.ts diff --git a/src/ucode/mods.py b/src/ucode/mods.py index b0f4a6d8e..4d2fdc3e8 100644 --- a/src/ucode/mods.py +++ b/src/ucode/mods.py @@ -64,4 +64,7 @@ def write_mod(plugin_dir: Path, mod: ClaudeMod) -> None: # ug's smart-routing UI mod: a register.ts entry that composes the concern # modules it imports (today the status band). -SMART_ROUTING_UI = ClaudeMod(source="register.ts", extra=("smart-routing-status.ts",)) +SMART_ROUTING_UI = ClaudeMod( + source="register.ts", + extra=("smart-routing-status.ts", "subagent-routing.ts"), +) diff --git a/src/ucode/smart_routing/claude_hooks.py b/src/ucode/smart_routing/claude_hooks.py index 837326c00..d84541a2a 100644 --- a/src/ucode/smart_routing/claude_hooks.py +++ b/src/ucode/smart_routing/claude_hooks.py @@ -20,9 +20,15 @@ FIRST_PROMPT_SOCKET_ENV = "UCODE_CLAUDE_V2_SOCKET" -def sync_smart_routing_hooks(doc: dict, state: dict, *, enabled: bool) -> None: - """Synchronize ucode-managed routing hooks in a Claude settings document.""" - groups = _routing_hook_groups(state) if enabled else {} +def sync_smart_routing_hooks( + doc: dict, state: dict, *, enabled: bool, subagent_status: str | None = "Routing subagent model" +) -> None: + """Synchronize ucode-managed routing hooks in a Claude settings document. + + ``subagent_status`` is the spinner label shown while the subagent-spawn hook + runs; pass ``None`` to show no label (e.g. when a mod renders the routing). + """ + groups = _routing_hook_groups(state, subagent_status=subagent_status) if enabled else {} hooks.sync_managed_hooks(doc, ROUTING_HOOK_COMMAND_MARKER, groups) @@ -48,7 +54,7 @@ def sync_first_prompt_hook(doc: dict, executable: str) -> None: hooks.sync_managed_hooks(doc, FIRST_PROMPT_HOOK_MARKER, groups) -def _routing_hook_groups(state: dict) -> dict[str, list[dict]]: +def _routing_hook_groups(state: dict, *, subagent_status: str | None) -> dict[str, list[dict]]: route_argv = _routing_hook_argv(state, "route-subagent") session_argv = _routing_hook_argv(state, "session-start") subagent_argv = _routing_hook_argv(state, "record-subagent") @@ -56,7 +62,7 @@ def _routing_hook_groups(state: dict) -> dict[str, list[dict]]: "PreToolUse": [ { "matcher": "Agent|Task", - "hooks": [_routing_command_hook(route_argv, status="Routing subagent model")], + "hooks": [_routing_command_hook(route_argv, status=subagent_status)], } ], "SessionStart": [ diff --git a/src/ucode/smart_routing/v2.py b/src/ucode/smart_routing/v2.py index 3a9249992..54f46f6e5 100644 --- a/src/ucode/smart_routing/v2.py +++ b/src/ucode/smart_routing/v2.py @@ -28,6 +28,7 @@ write_text_file, ) from ucode.constants import ( + ENABLE_CLAUDE_CODE_MODS_ENV_VAR, ENABLE_SMART_ROUTING_ENV_VAR, ENABLE_SUBAGENT_ROUTING_ENV_VAR, LOOPBACK_HOST, @@ -419,15 +420,26 @@ def route_claude_pre_tool_use( route.decision, route.routed_model, ) + updated_input = { + **{key: value for key, value in route.tool_input.items() if key != "model"}, + "subagent_type": _routed_claude_agent_name(route.routed_model), + } + # Under mods, the subagent-routing mod renders the routing in the TUI, so drop + # the plain-text notice entirely and hand the mod the model + rationale by task. + if claude_code_mods_enabled(): + _record_mod_routing(route.task, route.routed_model, route.decision.rationale) + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": updated_input, + } + } routing_message = claude_routing.SUBAGENT_NOTICE_CONFIG.message( route.decision, route.routed_model, route.tool_input, ) - updated_input = { - **{key: value for key, value in route.tool_input.items() if key != "model"}, - "subagent_type": _routed_claude_agent_name(route.routed_model), - } hook_output = { "hookEventName": "PreToolUse", "permissionDecision": "allow", @@ -437,6 +449,25 @@ def route_claude_pre_tool_use( return {"systemMessage": routing_message, "hookSpecificOutput": hook_output} +def _record_mod_routing(task: str, model: str, rationale: str) -> None: + """Write a routing record the subagent-routing mod reads (keyed by task). + + Lives next to the session env file so the mod can find it from + UCODE_SESSION_ENV_FILE; best-effort, never blocks routing. + """ + session_file = os.environ.get(SESSION_ENV_VAR) + if not session_file: + return + path = Path(session_file).with_name("mod-routing.json") + try: + existing = read_json_safe(path) + records = existing if isinstance(existing, dict) else {} + records[task] = {"model": model, "rationale": rationale} + config_io.atomic_write_json(path, records) + except OSError: + pass + + def _is_claude_target_model(value: object) -> bool: if not isinstance(value, str): return False @@ -539,6 +570,11 @@ def launch_claude( env[ENABLE_SMART_ROUTING_ENV_VAR] = "1" else: env[ENABLE_SUBAGENT_ROUTING_ENV_VAR] = "1" + # Propagate the mods flag into the hook subprocesses' environment so the + # subagent routing hook knows to stay silent (the mod renders instead). + mods_enabled = claude_code_mods_enabled() + if mods_enabled: + env[ENABLE_CLAUDE_CODE_MODS_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.") @@ -547,7 +583,13 @@ def launch_claude( **state, "claude_models": {str(index): model for index, model in enumerate(model_ids)}, } - sync_smart_routing_hooks(settings, routing_state, enabled=True) + # The spinner notice is redundant when the mod renders the routing itself. + sync_smart_routing_hooks( + settings, + routing_state, + enabled=True, + subagent_status=None if mods_enabled else "Routing subagent model", + ) if route_first_prompt: sync_first_prompt_hook(settings, hook_executable) model_setting = _ClaudeModelSettingGuard(user_settings_path) diff --git a/tests/test_claude_smart_routing_v2.py b/tests/test_claude_smart_routing_v2.py index f8227c07f..2ecd94498 100644 --- a/tests/test_claude_smart_routing_v2.py +++ b/tests/test_claude_smart_routing_v2.py @@ -622,6 +622,7 @@ def fake_select(workspace, token, task, route_options, resolve, **kwargs): def test_routes_agent_prompt_with_initialized_model_menu(self, tmp_path, monkeypatch): captured = {} decisions_path = tmp_path / "decisions.jsonl" + monkeypatch.delenv("ENABLE_CLAUDE_CODE_MODS", raising=False) # plain-text notice path monkeypatch.setattr(v2.claude_routing, "DECISIONS_PATH", decisions_path) def fake_select(workspace, token, task, route_options, resolve, **kwargs): @@ -684,6 +685,63 @@ def fake_select(workspace, token, task, route_options, resolve, **kwargs): decision_record = json.loads(decisions_path.read_text()) assert decision_record["requested_model"] == "system.ai.claude-opus-4-8" + def test_mods_suppress_notice_and_record_routing_for_the_mod(self, tmp_path, monkeypatch): + monkeypatch.setenv("ENABLE_CLAUDE_CODE_MODS", "1") + monkeypatch.setenv(v2.SESSION_ENV_VAR, str(tmp_path / "env.json")) + monkeypatch.setattr( + routing, + "select_route", + lambda *a, **k: ( + routing.RoutingDecision( + model="system.ai.claude-opus-4-8", + raw_model="claude-opus-4-8", + rationale="Routed to Low because its task-only profile is low", + ), + None, + ), + ) + + output = v2.route_claude_pre_tool_use( + { + "tool_name": "Agent", + "tool_input": {"prompt": "inspect the parser", "model": "sonnet"}, + }, + workspace="https://example.com", + token="token", + available_models=["system.ai.claude-opus-4-8", "databricks-claude-sonnet-5"], + ) + + # The mod renders the routing, so no plain-text notice is shown... + assert "systemMessage" not in output + assert "permissionDecisionReason" not in output["hookSpecificOutput"] + # ...but the model rewrite still happens... + assert "model" not in output["hookSpecificOutput"]["updatedInput"] + # ...and the model + rationale are handed to the mod, keyed by task. + records = json.loads((tmp_path / "mod-routing.json").read_text()) + assert records["inspect the parser"] == { + "model": "system.ai.claude-opus-4-8", + "rationale": "Routed to Low because its task-only profile is low", + } + + def test_subagent_hook_status_label_is_omittable(self): + with_label: dict = {} + claude_hooks.sync_smart_routing_hooks( + with_label, + {"workspace": "https://x"}, + enabled=True, + subagent_status="Routing subagent model", + ) + silent: dict = {} + claude_hooks.sync_smart_routing_hooks( + silent, {"workspace": "https://x"}, enabled=True, subagent_status=None + ) + + def pretooluse_hook(doc: dict) -> dict: + return doc["hooks"]["PreToolUse"][0]["hooks"][0] + + assert pretooluse_hook(with_label)["statusMessage"] == "Routing subagent model" + assert "statusMessage" not in pretooluse_hook(silent) + def test_writes_routed_agents_as_launch_scoped_plugin(self, tmp_path): plugin_dir = tmp_path / "routing-plugin" models = ["databricks-claude-opus-4-8", "system.ai.glm-5-3"] @@ -716,7 +774,8 @@ def test_includes_mod_when_requested(self, tmp_path): assert hooks == {"modules": ["./register.ts"]} for name in (mod.source, *mod.extra): assert (hooks_dir / name).is_file() - assert "registerStatusBand" in (hooks_dir / "register.ts").read_text() + entry = (hooks_dir / "register.ts").read_text() + assert "registerStatusBand" in entry and "registerSubagentRouting" in entry status = (hooks_dir / "smart-routing-status.ts").read_text() assert "unity gateway smart router" in status @@ -726,6 +785,9 @@ def test_includes_mod_when_requested(self, tmp_path): assert v2.ENABLE_SUBAGENT_ROUTING_ENV_VAR in status assert v2.SESSION_ENV_VAR in status + subagents = (hooks_dir / "subagent-routing.ts").read_text() + assert "ui.render" in subagents and "ToolUse" in subagents + def test_leaves_non_claude_custom_agent_model_unchanged(self): definitions = v2._routed_claude_agent_definitions(["catalog.schema.gpt-5"]) diff --git a/typescript/claude-mods/register.ts b/typescript/claude-mods/register.ts index 458433917..63022c136 100644 --- a/typescript/claude-mods/register.ts +++ b/typescript/claude-mods/register.ts @@ -1,13 +1,14 @@ import type { Register } from 'claude-code' import { registerStatusBand } from './smart-routing-status' +import { registerSubagentRouting } from './subagent-routing' // Entry for ug's smart-routing UI mod. Claude Code loads one hooks module per -// plugin, so this file imports and composes the concerns; today just the status -// band above the prompt, with more (e.g. the per-subagent routing line) importing -// here. Written into the launch-scoped routing plugin under -// ENABLE_CLAUDE_CODE_MODS (see ucode.mods); visualization only, ug's Python hooks -// still route. +// plugin, so this file imports and composes the concerns: the status band above +// the prompt and the compact per-subagent routing line. Written into the +// launch-scoped routing plugin under ENABLE_CLAUDE_CODE_MODS (see ucode.mods); +// visualization only, ug's Python hooks still route. export const register: Register = on => { registerStatusBand(on) + registerSubagentRouting(on) } diff --git a/typescript/claude-mods/subagent-routing.ts b/typescript/claude-mods/subagent-routing.ts new file mode 100644 index 000000000..bbfae3763 --- /dev/null +++ b/typescript/claude-mods/subagent-routing.ts @@ -0,0 +1,94 @@ +import type { Register } from 'claude-code' + +type On = Parameters[0] + +// The subagent-routing concern of ug's smart-routing UI mod (composed by +// register.ts). Under ENABLE_CLAUDE_CODE_MODS, ug suppresses its plain-text +// subagent routing notice and this stacks a compact block under each Agent row: +// +// ⏺ Agent(map the token refresh flow) +// ↳ explorer claude-sonnet-5 ●○○ low +// Reason: Routed to Low because its task-only profile is low: an explicit target… +// +// The routed model comes off the Agent row's result (resolvedModel); the subagent +// name, tier, and reason come from the router rationale and the tool input, which +// ug's Python subagent hook writes (keyed by task) into a per-session file next to +// UCODE_SESSION_ENV_FILE. + +type Tier = 'low' | 'medium' | 'high' +type RoutingRecord = { model?: string; rationale?: string } + +// Rationale looks like: "Routed to Low because ". The meter needs just the +// tier; the Reason line keeps the whole rationale verbatim like the old box did. +function parseTier(text: string | undefined): Tier | undefined { + const m = text?.match(/routed to (low|medium|high) because/i) + return m ? (m[1].toLowerCase() as Tier) : undefined +} + +// Reason text as the plain-text box showed it; only the trailing "Request: " +// echo is dropped, since the task is already named on the Agent row. +function parseReason(text: string | undefined): string { + if (!text) return '' + return text.replace(/[.\s]*Request:.*$/is, '').trim() +} + +function meter(tier: Tier | undefined): string { + if (!tier) return '' + const filled = { low: 1, medium: 2, high: 3 }[tier] + return '●'.repeat(filled) + '○'.repeat(3 - filled) + ' ' + tier +} + +// Drop the provider prefix so the model reads compactly (system.ai.X -> X). +const shortModel = (model: string): string => model.replace(/^system\.ai\./, '').replace(/^databricks-/, '') + +const taskKey = (input: any): string => + String(input?.prompt ?? input?.description ?? input?.subagent_type ?? '') + +async function readRouting($: any): Promise> { + let file: string | undefined + try { + file = await $.env.get('UCODE_SESSION_ENV_FILE') + } catch {} + if (!file) return {} + const dir = file.slice(0, Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\'))) + try { + const parsed = JSON.parse(await $.fs.read(`${dir}/mod-routing.json`)) + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return {} + } +} + +export const registerSubagentRouting = (on: On): void => { + on('ui.render', { component: 'ToolUse' }, async ($, e, next) => { + const original = await next(e) + const props = e.props as any + if (props?.tool !== 'Agent' && props?.tool !== 'Task') return original + + const routing = await readRouting($) + const record = routing[taskKey(props.input)] ?? {} + const model: string | undefined = props.output?.resolvedModel ?? record.model + const tier = parseTier(record.rationale) + const reason = parseReason(record.rationale) + const name = String(props.input?.subagent_type ?? '').trim() + + const { Box, Text } = $.ui.resolve(e) + // Until the subagent has a routed model, show "routing…" rather than a blank + // row — routing can take a moment and this is the only live progress signal. + const header = Box({ + flexDirection: 'row', + children: [ + Text({ dimColor: true, children: [' ↳ '] }), + ...(name ? [Text({ color: 'primary', bold: true, children: [`${name} `] })] : []), + Text({ color: 'primary', children: [model ? shortModel(model) : 'routing…'] }), + Text({ color: 'primary', dimColor: true, children: [tier ? ' ' + meter(tier) : ''] }), + ], + }) + // Stack the Reason on its own line below so neither the model nor the + // rationale gets squeezed/truncated by a shared row. + const children = reason + ? [original, header, Text({ dimColor: true, children: [` Reason: ${reason}`] })] + : [original, header] + return Box({ flexDirection: 'column', children }) + }) +} From 46f9f77061fbc145b90930dd9348234ea2c39b4f Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Tue, 13 Oct 2026 23:58:34 +0000 Subject: [PATCH 04/18] Render Smart Router status in Claude and verify CUJ 4 toggles --- docs/claude-mod.md | 8 ++- src/ucode/agents/claude_mods/constants.ts | 2 + src/ucode/agents/claude_mods/register.ts | 7 +-- .../claude_mods/smart-routing-status.ts | 62 +++++++++++++++++++ src/ucode/smart_routing/v2.py | 2 +- tests/README.md | 4 ++ tests/e2e_cuj/README.md | 4 ++ tests/e2e_cuj/test_cuj4_smart_routing.py | 43 ++++++++++++- tests/integration/README.md | 4 ++ .../test_ug_smart_routing_hooks.py | 2 +- tests/test_claude_mod.py | 5 +- tests/test_cuj_evidence.py | 29 ++++++++- tests/test_skills.py | 7 +++ 13 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 src/ucode/agents/claude_mods/smart-routing-status.ts diff --git a/docs/claude-mod.md b/docs/claude-mod.md index eff1837f8..a9734e93f 100644 --- a/docs/claude-mod.md +++ b/docs/claude-mod.md @@ -4,14 +4,16 @@ UG's smart-routed Claude launcher loads `hooks/register.ts` through the existing launch-scoped `ug-smart-router` plugin. Claude Code 2.1.290 or newer is required. The existing `_write_routed_claude_plugin()` copies the packaged TypeScript entry point -from `src/ucode/agents/claude_mods/` and writes `hooks/hooks.json` into that plugin. Both full and subagent-only smart +from `src/ucode/agents/claude_mods/` and writes `hooks/hooks.json` into that plugin. +Every routed launch includes the status band above the prompt. Both full and subagent-only smart routing launches use this path, and the existing temporary-directory lifecycle cleans up the files when Claude exits. `constants.ts` holds the values the mod shares with UG's CLI and the gateway: the recipe -header name, the disabled recipe, and the CLI module, agent, and routing flags. It ships +header name, the disabled recipe, the CLI module, agent, and routing flags, and the +routing keys used to read session overrides. It ships beside the entry point and stays out of `hooks.json`, which lists only `register.ts` as a -module. Environment-variable names are deliberately not collected there. Claude's +module. Claude's capability scanner requires a literal name at each `$.env.get`/`$.env.set` call site so it can list what the module reads and writes, and `plugin validate` rejects a variable in that position: "$.env.get takes a literal name as its first argument". Keep those names inline. diff --git a/src/ucode/agents/claude_mods/constants.ts b/src/ucode/agents/claude_mods/constants.ts index 4075ef5a1..f20001a12 100644 --- a/src/ucode/agents/claude_mods/constants.ts +++ b/src/ucode/agents/claude_mods/constants.ts @@ -4,3 +4,5 @@ export const CLI_MODULE = 'ucode.cli'; export const CLI_AGENT = 'claude'; export const ENABLE_ROUTING_FLAG = '--enable-smart-routing'; export const DISABLE_ROUTING_FLAG = '--disable-smart-routing'; +export const SMART_ROUTING_KEY = 'ENABLE_SMART_ROUTING_V2'; +export const SUBAGENT_ONLY_KEY = 'ENABLE_SMART_ROUTING_SUBAGENT_ONLY'; diff --git a/src/ucode/agents/claude_mods/register.ts b/src/ucode/agents/claude_mods/register.ts index 3bbce7162..27edf8b49 100644 --- a/src/ucode/agents/claude_mods/register.ts +++ b/src/ucode/agents/claude_mods/register.ts @@ -1,4 +1,5 @@ import type { EngineInterface, On, PluginOptions } from 'claude-code'; +import { registerStatusBand } from './smart-routing-status.ts'; import { CLI_AGENT, CLI_MODULE, @@ -100,12 +101,10 @@ export function register(on: On, options: PluginOptions): void { if (!launchedEnabled) return {text: 'Smart Router was not enabled for this session.'}; try { await setRouting($, options, action === 'on', true); - const guidance = options.ENABLE_SMART_ROUTER_ORCHESTRATOR !== '1' ? '' : action === 'off' - ? ' Earlier Smart Router Orchestrator instructions are superseded. Stop automatic delegation; honor explicit subagent requests using native model selection. Existing children can finish.' - : ' Apply the smart-router-orchestrator skill to further work.'; - return {text: `Smart Router is ${action} for this session.${guidance}`}; + return {text: `Smart Router is ${action}`}; } catch { return {text: 'Could not update Smart Router. Check the recipe, ANTHROPIC_CUSTOM_HEADERS, and environment permissions; retry or restart through UG.'}; } }); + registerStatusBand(on); } diff --git a/src/ucode/agents/claude_mods/smart-routing-status.ts b/src/ucode/agents/claude_mods/smart-routing-status.ts new file mode 100644 index 000000000..cfef4b3c8 --- /dev/null +++ b/src/ucode/agents/claude_mods/smart-routing-status.ts @@ -0,0 +1,62 @@ +import type { On } from 'claude-code' +import { SMART_ROUTING_KEY, SUBAGENT_ONLY_KEY } from './constants.ts' + +const BANNER = 'unity gateway smart router' + +type RoutingState = { enabled: boolean; firstPrompt: boolean } + +// Claude's capability scanner requires literal names in $.env.get calls. +async function readRoutingState($: any): Promise { + let full: unknown + let subagentOnly: unknown + let file: unknown + try { + full = await $.env.get('ENABLE_SMART_ROUTING_V2') + } catch {} + try { + subagentOnly = await $.env.get('ENABLE_SMART_ROUTING_SUBAGENT_ONLY') + } catch {} + try { + file = await $.env.get('UCODE_SESSION_ENV_FILE') + } catch {} + if (typeof file === 'string' && file) { + try { + const overrides = JSON.parse(await $.fs.read(file)) + if (overrides && typeof overrides === 'object') { + if (SMART_ROUTING_KEY in overrides) full = overrides[SMART_ROUTING_KEY] + if (SUBAGENT_ONLY_KEY in overrides) subagentOnly = overrides[SUBAGENT_ONLY_KEY] + } + } catch {} + } + return { + enabled: full === '1' || subagentOnly === '1', + firstPrompt: full === '1' && subagentOnly !== '1', + } +} + +export const registerStatusBand = (on: On): void => { + on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { + const theirs = await next(e) + const { Box, Text } = $.ui.resolve(e) + const { enabled, firstPrompt } = await readRoutingState($) + + const pill = enabled + ? Text({ bold: true, color: 'black', backgroundColor: 'green', children: [' ON '] }) + : Text({ bold: true, color: 'black', backgroundColor: 'gray', children: [' OFF '] }) + const trailer = enabled + ? Text({ + dimColor: true, + italic: true, + children: ['— ' + (firstPrompt ? 'routing first prompt + subagents' : 'routing subagents')], + }) + : Text({ dimColor: true, children: ["— Reenable with '/smart-router on'"] }) + + const line = Box({ + flexDirection: 'row', + columnGap: 1, + children: [Text({ bold: true, children: [BANNER] }), pill, trailer], + }) + const children = theirs ? [line, theirs] : [line] + return Box({ flexDirection: 'column', children }) + }) +} diff --git a/src/ucode/smart_routing/v2.py b/src/ucode/smart_routing/v2.py index c2aaa7c97..7d083bd42 100644 --- a/src/ucode/smart_routing/v2.py +++ b/src/ucode/smart_routing/v2.py @@ -395,7 +395,7 @@ def _write_routed_claude_plugin(plugin_dir: Path, model_ids: list[str]) -> None: orchestrator.add_claude_agents(plugin_dir) mods = Path(__file__).parents[1] / "agents" / "claude_mods" # constants.ts is imported by the entry point, so it ships beside it rather than as a module. - for name in ("register.ts", "constants.ts"): + for name in ("register.ts", "constants.ts", "smart-routing-status.ts"): write_text_file(plugin_dir / "hooks" / name, (mods / name).read_text(encoding="utf-8")) write_json_file(plugin_dir / "hooks" / "hooks.json", {"modules": ["./register.ts"]}) diff --git a/tests/README.md b/tests/README.md index 1f60034e0..3df2c4992 100644 --- a/tests/README.md +++ b/tests/README.md @@ -19,6 +19,10 @@ TypeScript event-forwarding and launch-option tests in CI. The existing Claude skill-toggle integration journey checks the live recipe headers across on/off/on. See [the mod guide](../docs/claude-mod.md). +CUJ 4's preset journeys check Claude's bold green ON and gray OFF status band, +routing scope, and updates after off/on toggles. Offline terminal checks reject +unstyled text, stale states, wrong routing scope, and command confirmations. + Dedicated CUJs reuse `integration/utils` session/terminal mechanics and file-task and transcript readers, not its config stubs or pytest fixtures. Prompt/model correlation remains CUJ-specific. Concurrent runs may read the same CUJ workspace. diff --git a/tests/e2e_cuj/README.md b/tests/e2e_cuj/README.md index 6fea6f7b8..ab370650e 100644 --- a/tests/e2e_cuj/README.md +++ b/tests/e2e_cuj/README.md @@ -100,6 +100,10 @@ The wait uses the child's answer directly; parent-answer reconstruction is reser Offline checks reject parent-only answers and accept child answers before the parent replies. Offline evidence regressions do not establish a live CUJ pass. Selector cases have separate TUI artifact names, and the session environment is restored afterward. +Each Claude preset session checks the visible bold green ON badge and routing scope, +then a gray OFF badge and restored green ON badge after `/smart-router off` and `on`. +These checks read the terminal's rendered text and colors; echoed command confirmations +do not satisfy them. Offline terminal cases reject stale states, wrong scope, and unstyled text. Claude sessions start in auto mode; unless a routed first prompt switches the session to Haiku (which leaves it in manual mode), its classifier requests through the recording proxy trigger Claude's informational auto-mode classifier billing notice over the transcript. The CUJ terminal diff --git a/tests/e2e_cuj/test_cuj4_smart_routing.py b/tests/e2e_cuj/test_cuj4_smart_routing.py index 37d3cd5d3..d044276ee 100644 --- a/tests/e2e_cuj/test_cuj4_smart_routing.py +++ b/tests/e2e_cuj/test_cuj4_smart_routing.py @@ -1,5 +1,6 @@ """Smart-routing CUJ: execute once, then report each contract assertion separately.""" +import re from dataclasses import dataclass import pytest @@ -42,6 +43,28 @@ ORCHESTRATOR_CONTEXT = "Smart Router Orchestrator is on for this session." +def _smart_router_band_shown(tui, *, enabled, first_prompt): + state = "ON" if enabled else "OFF" + trailer = ( + ("— routing first prompt + subagents" if first_prompt else "— routing subagents") + if enabled + else "— Reenable with '/smart-router on'" + ) + for row, line in enumerate(tui.screen.display): + match = re.fullmatch( + rf"\s*unity gateway smart router\s+(?P{state})\s+{re.escape(trailer)}\s*", + line, + ) + if match: + return all( + tui.screen.buffer[row][column].bold + and tui.screen.buffer[row][column].fg == "black" + and tui.screen.buffer[row][column].bg == ("green" if enabled else "brightblack") + for column in range(*match.span("state")) + ) + return False + + @dataclass(frozen=True) class SessionCase: agent: str @@ -247,7 +270,8 @@ def test_smart_router_config_version(self, cuj, SMART_ROUTER_CONFIG_VERSION): Expected: each agent's resolved flags determine first-prompt routing, orchestration, and child routing. One native child completes the delegated - task even when routing is disabled. + task even when routing is disabled. Claude's status band shows a green ON + badge and the preset's routing scope, then gray OFF and green ON after toggles. """ session, workspace, recorder = cuj previous = session.env.get("SMART_ROUTER_CONFIG_VERSION") @@ -277,6 +301,13 @@ def test_smart_router_config_version(self, cuj, SMART_ROUTER_CONFIG_VERSION): recorder.prepare_launch() with Terminal(session, f"{SMART_ROUTER_CONFIG_VERSION}-{agent}", [agent]) as tui: tui.boot(timeout=150) + if agent == CLAUDE: + tui.wait_for( + lambda _, first_prompt=first_prompt_routed: _smart_router_band_shown( + tui, enabled=True, first_prompt=first_prompt + ), + "the green ON Smart Router band with the preset's routing scope", + ) tui.submit(task.prompt) tui.task(evidence, task) requests = recorder.requests_after(checkpoint) @@ -368,6 +399,16 @@ def test_smart_router_config_version(self, cuj, SMART_ROUTER_CONFIG_VERSION): ), agent if agent == CLAUDE: tui.wait_for_background_tasks() + for enabled in (False, True): + tui.submit(f"/smart-router {'on' if enabled else 'off'}") + tui.wait_for( + lambda _, enabled=enabled, first_prompt=first_prompt_routed: ( + _smart_router_band_shown( + tui, enabled=enabled, first_prompt=first_prompt + ) + ), + f"the {'green ON' if enabled else 'gray OFF'} Smart Router band", + ) tui.exit_normally() finally: if previous is None: diff --git a/tests/integration/README.md b/tests/integration/README.md index 4a28a9f4d..2b7a3fad3 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1009,6 +1009,10 @@ Native launch-option component tests (`native_claude/test_mod.py`) check two rea processes with different resolved routing baselines. Python cases cover Claude-specific presets, unknown selectors, and absent versus empty flags. These checks do not establish gateway/TUI coverage. +CUJ 4's preset journeys check Claude's visible green ON and gray OFF status band +and routing scope before work and after off/on toggles. Offline terminal checks +reject unstyled text, stale states, wrong scope, and command confirmations. + The plugin test host runs without a process capability, so the mod's session-control call is covered by the live skill-toggle journey rather than `claude plugin test`. No component check asserts that the launch paths withdraw the skills. diff --git a/tests/integration/test_ug_smart_routing_hooks.py b/tests/integration/test_ug_smart_routing_hooks.py index afb6f00fd..49c1bb822 100644 --- a/tests/integration/test_ug_smart_routing_hooks.py +++ b/tests/integration/test_ug_smart_routing_hooks.py @@ -152,7 +152,7 @@ def _toggle_with_skill( # Claude's mod handles this locally; routing changes are checked by the next child. tui.submit(invocation) tui.wait_for( - lambda screen: confirmation in screen, + lambda screen: f"Smart Router is {state}" in screen, f"the Smart Router {state} confirmation", timeout=120, ) diff --git a/tests/test_claude_mod.py b/tests/test_claude_mod.py index 8200763d1..584b5fa8d 100644 --- a/tests/test_claude_mod.py +++ b/tests/test_claude_mod.py @@ -14,13 +14,14 @@ def test_plugin_contains_mod_entry_point(tmp_path): assert json.loads((tmp_path / "hooks/hooks.json").read_text()) == {"modules": ["./register.ts"]} mods = Path(v2.__file__).parents[1] / "agents" / "claude_mods" - # constants.ts is imported by the entry point, so it ships beside it unmodified. - for name in ("register.ts", "constants.ts"): + # Imported helpers ship beside the entry point unmodified. + for name in ("register.ts", "constants.ts", "smart-routing-status.ts"): assert (tmp_path / "hooks" / name).read_bytes() == (mods / name).read_bytes() assert sorted(path.name for path in (tmp_path / "hooks").iterdir()) == [ "constants.ts", "hooks.json", "register.ts", + "smart-routing-status.ts", ] diff --git a/tests/test_cuj_evidence.py b/tests/test_cuj_evidence.py index 58ad66d53..da90f40cd 100644 --- a/tests/test_cuj_evidence.py +++ b/tests/test_cuj_evidence.py @@ -4,6 +4,7 @@ import json from types import SimpleNamespace +import pyte import pytest from tests.e2e_cuj.helpers.constants import CLAUDE, CODEX, INFERENCE_PATHS @@ -19,9 +20,35 @@ get_cuj_helper, ) from tests.e2e_cuj.helpers.tui_request_recorder import RecordedRequest, RecordedResponse -from tests.e2e_cuj.test_cuj4_smart_routing import _task_inference_request +from tests.e2e_cuj.test_cuj4_smart_routing import _smart_router_band_shown, _task_inference_request from tests.integration.utils.evidence import FileTask, read_jsonl from tests.integration.utils.provider_catalog import MODEL_SERVICE_PARENT_SCHEMA_HEADER +from tests.integration.utils.terminal import TerminalScreen + + +@pytest.mark.parametrize("enabled, first_prompt", [(True, True), (True, False), (False, False)]) +def test_cuj4_status_band_requires_current_state_scope_and_color(enabled, first_prompt): + screen = TerminalScreen(140, 4, lambda _: None) + stream = pyte.Stream(screen) + tui = SimpleNamespace(screen=screen) + state = "ON" if enabled else "OFF" + trailer = ( + ("— routing first prompt + subagents" if first_prompt else "— routing subagents") + if enabled + else "— Reenable with '/smart-router on'" + ) + prefix = "unity gateway smart router " + stream.feed(f"{prefix}\x1b[1;30;{42 if enabled else 100}m {state} \x1b[0m {trailer}") + assert _smart_router_band_shown(tui, enabled=enabled, first_prompt=first_prompt) + assert not _smart_router_band_shown(tui, enabled=not enabled, first_prompt=first_prompt) + if enabled: + assert not _smart_router_band_shown(tui, enabled=enabled, first_prompt=not first_prompt) + screen.reset() + stream.feed(f"{prefix}{state} {trailer}") + assert not _smart_router_band_shown(tui, enabled=enabled, first_prompt=first_prompt) + screen.reset() + stream.feed(f"Smart Router is {state.lower()} for this session.") + assert not _smart_router_band_shown(tui, enabled=enabled, first_prompt=first_prompt) def records(agent, task, model): diff --git a/tests/test_skills.py b/tests/test_skills.py index a55737bf0..f19ff8e56 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -107,6 +107,13 @@ def test_orchestrator_injects_the_installed_agent_instructions( assert context.endswith(f"\n\n{agent} instructions") assert context.endswith(installed.joinpath("SKILL.md").read_text()) + session_env.set_session_environment({"ENABLE_SMART_ROUTING_SUBAGENT_ONLY": "0"}) + output = orchestrator.hook_output(payload, agent=agent) + assert output["hookSpecificOutput"]["additionalContext"] == orchestrator.DISABLED_CONTEXT + session_env.set_session_environment({}) + output = orchestrator.hook_output(payload, agent=agent) + assert output["hookSpecificOutput"]["additionalContext"] == context + def test_uninstalls_one_skill_from_agent_roots(bundled_skill): source_skill, home = bundled_skill From 8431fb60d95f6bd3110ea468c41125e90d0859a3 Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Wed, 14 Oct 2026 01:12:46 +0000 Subject: [PATCH 05/18] Relax smart router status band assertions --- tests/README.md | 6 ++--- tests/e2e_cuj/README.md | 8 +++--- tests/e2e_cuj/test_cuj4_smart_routing.py | 31 +++++++----------------- tests/integration/README.md | 6 ++--- tests/test_cuj_evidence.py | 18 ++++---------- 5 files changed, 24 insertions(+), 45 deletions(-) diff --git a/tests/README.md b/tests/README.md index ef3b56a71..2a4e3e03a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -19,9 +19,9 @@ TypeScript event-forwarding and launch-option tests in CI. The existing Claude skill-toggle integration journey checks the live recipe headers across on/off/on. See [the mod guide](../docs/claude-mod.md). -CUJ 4's preset journeys check Claude's bold green ON and gray OFF status band, -routing scope, and updates after off/on toggles. Offline terminal checks reject -unstyled text, stale states, wrong routing scope, and command confirmations. +CUJ 4's preset journeys check Claude's ON/OFF status band, routing scope, and +updates after off/on toggles. Checks ignore terminal styling and trailing UI controls; +offline cases reject stale states, wrong routing scope, and command confirmations. Dedicated CUJs reuse `integration/utils` session/terminal mechanics and file-task and transcript readers, not its config stubs or pytest fixtures. Prompt/model diff --git a/tests/e2e_cuj/README.md b/tests/e2e_cuj/README.md index d2f3d6f92..a6ab95fb1 100644 --- a/tests/e2e_cuj/README.md +++ b/tests/e2e_cuj/README.md @@ -100,10 +100,10 @@ The wait uses the child's answer directly; parent-answer reconstruction is reser Offline checks reject parent-only answers and accept child answers before the parent replies. Offline evidence regressions do not establish a live CUJ pass. Selector cases have separate TUI artifact names, and the session environment is restored afterward. -Each Claude preset session checks the visible bold green ON badge and routing scope, -then a gray OFF badge and restored green ON badge after `/smart-router off` and `on`. -These checks read the terminal's rendered text and colors; echoed command confirmations -do not satisfy them. Offline terminal cases reject stale states, wrong scope, and unstyled text. +Each Claude preset session checks the visible ON state and routing scope, +then OFF and restored ON after `/smart-router off` and `on`. +These checks read the terminal's rendered text, ignoring styling and trailing UI controls. +Offline cases reject stale states, wrong scope, and echoed command confirmations. Claude sessions start in auto mode; unless a routed first prompt switches the session to Haiku (which leaves it in manual mode), its classifier requests through the recording proxy trigger Claude's informational auto-mode classifier billing notice over the transcript. The CUJ terminal diff --git a/tests/e2e_cuj/test_cuj4_smart_routing.py b/tests/e2e_cuj/test_cuj4_smart_routing.py index 0cce892c1..5a4734a05 100644 --- a/tests/e2e_cuj/test_cuj4_smart_routing.py +++ b/tests/e2e_cuj/test_cuj4_smart_routing.py @@ -45,24 +45,11 @@ def _smart_router_band_shown(tui, *, enabled, first_prompt): state = "ON" if enabled else "OFF" - trailer = ( - ("— routing first prompt + subagents" if first_prompt else "— routing subagents") - if enabled - else "— Reenable with '/smart-router on'" - ) - for row, line in enumerate(tui.screen.display): - match = re.fullmatch( - rf"\s*unity gateway smart router\s+(?P{state})\s+{re.escape(trailer)}\s*", - line, - ) - if match: - return all( - tui.screen.buffer[row][column].bold - and tui.screen.buffer[row][column].fg == "black" - and tui.screen.buffer[row][column].bg == ("green" if enabled else "brightblack") - for column in range(*match.span("state")) - ) - return False + pattern = rf"\s*unity gateway smart router\s+{state}\b" + if enabled: + scope = "first prompt + subagents" if first_prompt else "subagents" + pattern += rf"\s+—\s+routing\s+{re.escape(scope)}\b" + return any(re.match(pattern, line) for line in tui.screen.display) @dataclass(frozen=True) @@ -270,8 +257,8 @@ def test_smart_router_config_version(self, cuj, SMART_ROUTER_CONFIG_VERSION): Expected: each agent's resolved flags determine first-prompt routing, orchestration, and child routing. One native child completes the delegated - task even when routing is disabled. Claude's status band shows a green ON - badge and the preset's routing scope, then gray OFF and green ON after toggles. + task even when routing is disabled. Claude's status band shows ON and the + preset's routing scope, then OFF and ON after toggles. """ session, workspace, recorder = cuj previous = session.env.get("SMART_ROUTER_CONFIG_VERSION") @@ -306,7 +293,7 @@ def test_smart_router_config_version(self, cuj, SMART_ROUTER_CONFIG_VERSION): lambda _, first_prompt=first_prompt_routed: _smart_router_band_shown( tui, enabled=True, first_prompt=first_prompt ), - "the green ON Smart Router band with the preset's routing scope", + "the ON Smart Router band with the preset's routing scope", ) tui.submit(task.prompt) tui.task(evidence, task) @@ -407,7 +394,7 @@ def test_smart_router_config_version(self, cuj, SMART_ROUTER_CONFIG_VERSION): tui, enabled=enabled, first_prompt=first_prompt ) ), - f"the {'green ON' if enabled else 'gray OFF'} Smart Router band", + f"the {'ON' if enabled else 'OFF'} Smart Router band", ) tui.exit_normally() finally: diff --git a/tests/integration/README.md b/tests/integration/README.md index 5587206e8..b2301e728 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1010,9 +1010,9 @@ Native launch-option component tests (`native_claude/test_mod.py`) check two rea processes with different resolved routing baselines. Python cases cover Claude-specific presets, unknown selectors, and absent versus empty flags. These checks do not establish gateway/TUI coverage. -CUJ 4's preset journeys check Claude's visible green ON and gray OFF status band -and routing scope before work and after off/on toggles. Offline terminal checks -reject unstyled text, stale states, wrong scope, and command confirmations. +CUJ 4's preset journeys check Claude's visible ON/OFF status band and routing scope +before work and after off/on toggles. Checks ignore terminal styling and trailing +UI controls; offline cases reject stale states, wrong scope, and command confirmations. The plugin test host runs without a process capability, so the mod's session-control call is covered by the live skill-toggle journey rather than `claude plugin test`. No component diff --git a/tests/test_cuj_evidence.py b/tests/test_cuj_evidence.py index da90f40cd..b18a45e3d 100644 --- a/tests/test_cuj_evidence.py +++ b/tests/test_cuj_evidence.py @@ -4,7 +4,6 @@ import json from types import SimpleNamespace -import pyte import pytest from tests.e2e_cuj.helpers.constants import CLAUDE, CODEX, INFERENCE_PATHS @@ -23,31 +22,24 @@ from tests.e2e_cuj.test_cuj4_smart_routing import _smart_router_band_shown, _task_inference_request from tests.integration.utils.evidence import FileTask, read_jsonl from tests.integration.utils.provider_catalog import MODEL_SERVICE_PARENT_SCHEMA_HEADER -from tests.integration.utils.terminal import TerminalScreen @pytest.mark.parametrize("enabled, first_prompt", [(True, True), (True, False), (False, False)]) -def test_cuj4_status_band_requires_current_state_scope_and_color(enabled, first_prompt): - screen = TerminalScreen(140, 4, lambda _: None) - stream = pyte.Stream(screen) - tui = SimpleNamespace(screen=screen) +def test_cuj4_status_band_requires_current_state_and_scope(enabled, first_prompt): state = "ON" if enabled else "OFF" trailer = ( ("— routing first prompt + subagents" if first_prompt else "— routing subagents") if enabled else "— Reenable with '/smart-router on'" ) - prefix = "unity gateway smart router " - stream.feed(f"{prefix}\x1b[1;30;{42 if enabled else 100}m {state} \x1b[0m {trailer}") + tui = SimpleNamespace( + screen=SimpleNamespace(display=[f"unity gateway smart router {state} {trailer} [-]"]) + ) assert _smart_router_band_shown(tui, enabled=enabled, first_prompt=first_prompt) assert not _smart_router_band_shown(tui, enabled=not enabled, first_prompt=first_prompt) if enabled: assert not _smart_router_band_shown(tui, enabled=enabled, first_prompt=not first_prompt) - screen.reset() - stream.feed(f"{prefix}{state} {trailer}") - assert not _smart_router_band_shown(tui, enabled=enabled, first_prompt=first_prompt) - screen.reset() - stream.feed(f"Smart Router is {state.lower()} for this session.") + tui.screen.display = [f"Smart Router is {state.lower()} for this session."] assert not _smart_router_band_shown(tui, enabled=enabled, first_prompt=first_prompt) From 68a6b551c209d71388300a0ef5da38ccdaa49e86 Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Wed, 14 Oct 2026 03:02:38 +0000 Subject: [PATCH 06/18] Remove tier meter from subagent routing display --- .../agents/claude_mods/subagent-routing.ts | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/src/ucode/agents/claude_mods/subagent-routing.ts b/src/ucode/agents/claude_mods/subagent-routing.ts index e204ffc70..f48f30c5d 100644 --- a/src/ucode/agents/claude_mods/subagent-routing.ts +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -5,24 +5,16 @@ import type { On } from 'claude-code' // subagent routing notice and this stacks a compact block under each Agent row: // // ⏺ Agent(map the token refresh flow) -// ↳ explorer claude-sonnet-5 ●○○ low -// Reason: Routed to Low because its task-only profile is low: an explicit target… +// ↳ explorer claude-sonnet-5 +// Reason: A focused task with an explicit target… // // The routed model comes off the Agent row's result (resolvedModel); the subagent -// name, tier, and reason come from the router rationale and the tool input, which +// name and reason come from the router rationale and the tool input, which // ug's Python subagent hook writes (keyed by task) into a per-session file next to // UCODE_SESSION_ENV_FILE. -type Tier = 'low' | 'medium' | 'high' type RoutingRecord = { model?: string; rationale?: string } -// Rationale looks like: "Routed to Low because ". The meter needs just the -// tier; the Reason line keeps the whole rationale verbatim like the old box did. -function parseTier(text: string | undefined): Tier | undefined { - const m = text?.match(/routed to (low|medium|high) because/i) - return m ? (m[1].toLowerCase() as Tier) : undefined -} - // Reason text as the plain-text box showed it; only the trailing "Request: " // echo is dropped, since the task is already named on the Agent row. function parseReason(text: string | undefined): string { @@ -30,12 +22,6 @@ function parseReason(text: string | undefined): string { return text.replace(/[.\s]*Request:.*$/is, '').trim() } -function meter(tier: Tier | undefined): string { - if (!tier) return '' - const filled = { low: 1, medium: 2, high: 3 }[tier] - return '●'.repeat(filled) + '○'.repeat(3 - filled) + ' ' + tier -} - // Drop the provider prefix so the model reads compactly (system.ai.X -> X). const shortModel = (model: string): string => model.replace(/^system\.ai\./, '').replace(/^databricks-/, '') @@ -68,7 +54,6 @@ export const registerSubagentRouting = (on: On): void => { const routing = await readRouting($) const record = routing[taskKey(props.input)] ?? {} const model: string | undefined = props.output?.resolvedModel ?? record.model - const tier = parseTier(record.rationale) const reason = parseReason(record.rationale) const name = String(props.input?.subagent_type ?? '').trim() @@ -81,7 +66,6 @@ export const registerSubagentRouting = (on: On): void => { Text({ dimColor: true, children: [' ↳ '] }), ...(name ? [Text({ color: 'primary', bold: true, children: [`${name} `] })] : []), Text({ color: 'primary', children: [model ? shortModel(model) : 'routing…'] }), - Text({ color: 'primary', dimColor: true, children: [tier ? ' ' + meter(tier) : ''] }), ], }) // Stack the Reason on its own line below so neither the model nor the From d722bbbdec472792ab11181ff709614bb17366b7 Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Wed, 14 Oct 2026 03:06:29 +0000 Subject: [PATCH 07/18] Remove Claude mods flag and inline plugin source copying --- docs/claude-mod.md | 8 +-- .../agents/claude_mods/subagent-routing.ts | 4 +- src/ucode/constants.py | 12 ---- src/ucode/mods.py | 57 ------------------- src/ucode/smart_routing/v2.py | 46 ++++----------- tests/README.md | 2 +- tests/integration/README.md | 2 +- tests/test_agent_claude.py | 16 +----- tests/test_claude_smart_routing_v2.py | 30 +++++----- 9 files changed, 33 insertions(+), 144 deletions(-) delete mode 100644 src/ucode/mods.py diff --git a/docs/claude-mod.md b/docs/claude-mod.md index 50f6b388e..dd7eff067 100644 --- a/docs/claude-mod.md +++ b/docs/claude-mod.md @@ -9,11 +9,9 @@ Every routed launch includes the status band above the prompt. Both full and sub routing launches use this path, and the existing temporary-directory lifecycle cleans up the files when Claude exits. -With `ENABLE_CLAUDE_CODE_MODS=1` (or `true`), the same entry point also renders -subagent routing details under Agent/Task rows. Python hooks write the model and -rationale to the session's `mod-routing.json` and suppress the plain-text notice -and spinner label. Without the flag, those notices remain and the subagent renderer -passes rows through unchanged. +The same entry point renders subagent routing details under Agent/Task rows. +Python hooks write the model and rationale to the session's `mod-routing.json` +and suppress the plain-text notice and spinner label. `constants.ts` holds the values the mod shares with UG's CLI and the gateway: the recipe header name, the disabled recipe, the CLI module, agent, and routing flags, and the diff --git a/src/ucode/agents/claude_mods/subagent-routing.ts b/src/ucode/agents/claude_mods/subagent-routing.ts index f48f30c5d..2d5f53b40 100644 --- a/src/ucode/agents/claude_mods/subagent-routing.ts +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -1,7 +1,7 @@ import type { On } from 'claude-code' // The subagent-routing concern of ug's smart-routing UI mod (composed by -// register.ts). Under ENABLE_CLAUDE_CODE_MODS, ug suppresses its plain-text +// register.ts). ug suppresses its plain-text // subagent routing notice and this stacks a compact block under each Agent row: // // ⏺ Agent(map the token refresh flow) @@ -48,8 +48,6 @@ export const registerSubagentRouting = (on: On): void => { const original = await next(e) const props = e.props as any if (props?.tool !== 'Agent' && props?.tool !== 'Task') return original - const enabled = await $.env.get('ENABLE_CLAUDE_CODE_MODS') - if (enabled?.trim().toLowerCase() !== '1' && enabled?.trim().toLowerCase() !== 'true') return original const routing = await readRouting($) const record = routing[taskKey(props.input)] ?? {} diff --git a/src/ucode/constants.py b/src/ucode/constants.py index be9d84d67..b6fb749e3 100644 --- a/src/ucode/constants.py +++ b/src/ucode/constants.py @@ -1,8 +1,5 @@ """Shared UCode constants.""" -import os -from collections.abc import Mapping - LOCALHOST = "localhost" LOOPBACK_HOST = "127.0.0.1" @@ -21,15 +18,6 @@ ENABLE_SMART_ROUTER_ORCHESTRATOR_ENV_VAR, ) -ENABLE_CLAUDE_CODE_MODS_ENV_VAR = "ENABLE_CLAUDE_CODE_MODS" - - -def claude_code_mods_enabled(env: Mapping[str, str] | None = None) -> bool: - """Whether the Claude Code mods experience is enabled.""" - source = os.environ if env is None else env - return source.get(ENABLE_CLAUDE_CODE_MODS_ENV_VAR, "").strip().lower() in TRUTHY_ENV_VALUES - - MODEL_PROVIDER_SERVICE_HEADER = "Databricks-Model-Provider-Service" MODEL_SERVICE_PARENT_SCHEMA_HEADER = "Databricks-Model-Service-Parent-Schema" # Names the smart-router recipe (e.g. `task_v3`) in use; sent only when smart routing is enabled. diff --git a/src/ucode/mods.py b/src/ucode/mods.py deleted file mode 100644 index dd9a54df0..000000000 --- a/src/ucode/mods.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Shared infrastructure for shipping Claude Code "mods". - -A Claude Mod (Claude Code >= 2.1.290) is a plugin whose ``hooks/hooks.json`` -lists a ``modules`` entry pointing at an in-process TypeScript module that draws -in the TUI. ug keeps each mod's source as a real ``.ts`` file under -``agents/claude_mods/`` (shipped with the package) and -copies it into a launch-scoped plugin's ``hooks/`` on demand. - -Adding a mod going forward: - 1. Drop ``agents/claude_mods/.ts`` (exporting ``register``). - 2. Register it here: ``MY_MOD = ClaudeMod(source=".ts")``. - 3. Write it into a plugin dir with ``write_mod(plugin_dir, MY_MOD)``. - -Subagent display gating lives in :mod:`ucode.constants` (``ENABLE_CLAUDE_CODE_MODS`` / -``claude_code_mods_enabled``); the Claude version floor lives in :mod:`ucode.agents.claude`. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path - -from ucode.config_io import write_json_file, write_text_file - - -@dataclass(frozen=True) -class ClaudeMod: - """A mod ug can write into a launch-scoped plugin. - - ``source`` is the entry ``.ts`` under ``agents/claude_mods/`` (also the - hooks module filename). ``extra`` names sibling files it imports (e.g. a - shared ``pets.ts``); they are copied alongside so relative imports resolve. - """ - - source: str - extra: tuple[str, ...] = () - - -def _mods_source_dir() -> Path: - return Path(__file__).parent / "agents" / "claude_mods" - - -def write_mod(plugin_dir: Path, mod: ClaudeMod) -> None: - """Copy ``mod``'s TypeScript (entry + any siblings) into ``plugin_dir``'s hooks.""" - source_dir = _mods_source_dir() - hooks_dir = plugin_dir / "hooks" - for name in (mod.source, *mod.extra): - write_text_file(hooks_dir / name, (source_dir / name).read_text(encoding="utf-8")) - write_json_file(hooks_dir / "hooks.json", {"modules": [f"./{mod.source}"]}) - - -# ug's smart-routing UI mod: a register.ts entry that composes the concern -# modules it imports (the status band and subagent routing display). -SMART_ROUTING_UI = ClaudeMod( - source="register.ts", - extra=("constants.ts", "smart-routing-status.ts", "subagent-routing.ts"), -) diff --git a/src/ucode/smart_routing/v2.py b/src/ucode/smart_routing/v2.py index d7d4b80b6..1a7cdadfb 100644 --- a/src/ucode/smart_routing/v2.py +++ b/src/ucode/smart_routing/v2.py @@ -14,7 +14,7 @@ from tempfile import TemporaryDirectory from typing import NoReturn, TextIO -from ucode import config_io, mods +from ucode import config_io from ucode.codex_config import ( codex_config_args, custom_catalog_models, @@ -29,7 +29,6 @@ from ucode.constants import ( AGENT_CLAUDE, AGENT_CODEX, - ENABLE_CLAUDE_CODE_MODS_ENV_VAR, ENABLE_SMART_ROUTER_ORCHESTRATOR_ENV_VAR, ENABLE_SMART_ROUTING_ENV_VAR, ENABLE_SUBAGENT_ROUTING_ENV_VAR, @@ -37,7 +36,6 @@ SMART_ROUTER_CONFIG_VERSION_ENV_VAR, SMART_ROUTING_ENV_KEYS, TRUTHY_ENV_VALUES, - claude_code_mods_enabled, ) from ucode.custom_oauth import custom_oauth_cli_enabled, get_custom_client_token from ucode.databricks import ( @@ -399,7 +397,10 @@ def _write_routed_claude_plugin(plugin_dir: Path, model_ids: list[str]) -> None: ), ) orchestrator.add_claude_agents(plugin_dir) - mods.write_mod(plugin_dir, mods.SMART_ROUTING_UI) + mods_dir = Path(__file__).parents[1] / "agents" / "claude_mods" + for name in ("register.ts", "constants.ts", "smart-routing-status.ts", "subagent-routing.ts"): + write_text_file(plugin_dir / "hooks" / name, (mods_dir / name).read_text(encoding="utf-8")) + write_json_file(plugin_dir / "hooks" / "hooks.json", {"modules": ["./register.ts"]}) def _request_claude_routing_decision( @@ -497,32 +498,14 @@ def route_claude_pre_tool_use( **{key: value for key, value in route.tool_input.items() if key != "model"}, "subagent_type": _routed_claude_agent_name(route.routed_model), } - # Under mods, the subagent-routing mod renders the routing in the TUI, so drop - # the plain-text notice entirely and hand the mod the model + rationale by task. - if claude_code_mods_enabled(): - _record_mod_routing(route.task, route.routed_model, route.decision.rationale) - return { - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "updatedInput": updated_input, - } + _record_mod_routing(route.task, route.routed_model, route.decision.rationale) + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": updated_input, } - agent_name = claude_routing.SUBAGENT_NOTICE_CONFIG.name(route.tool_input) or "subagent" - if orchestrator.enabled(agent=AGENT_CLAUDE): - agent_name += " [orchestrator on]" - routing_message = claude_routing.SUBAGENT_NOTICE_CONFIG.message( - route.decision, - route.routed_model, - {**route.tool_input, "subagent_type": agent_name}, - ) - hook_output = { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "updatedInput": updated_input, - "permissionDecisionReason": routing_message, } - return {"systemMessage": routing_message, "hookSpecificOutput": hook_output} def _record_mod_routing(task: str, model: str, rationale: str) -> None: @@ -685,11 +668,6 @@ def launch_claude( env[ENABLE_SMART_ROUTING_ENV_VAR] = "1" else: env[ENABLE_SUBAGENT_ROUTING_ENV_VAR] = "1" - # Propagate the mods flag into the hook subprocesses' environment so the - # subagent routing hook knows to stay silent (the mod renders instead). - mods_enabled = claude_code_mods_enabled() - if mods_enabled: - env[ENABLE_CLAUDE_CODE_MODS_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.") @@ -703,7 +681,7 @@ def launch_claude( settings, routing_state, enabled=True, - subagent_status=None if mods_enabled else "Routing subagent model", + subagent_status=None, ) if route_first_prompt: sync_first_prompt_hook(settings, hook_executable) diff --git a/tests/README.md b/tests/README.md index 88e877511..24d8dcb50 100644 --- a/tests/README.md +++ b/tests/README.md @@ -517,6 +517,6 @@ Local Claude mod confirmation replaces the old saved-control-file/assistant-turn 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 mod component checks cover flag-gated routing notices, routing records, and +Subagent mod component checks cover routing records, suppression of plain-text notices, and packaging the subagent renderer beside the native status/toggle entry point. They do not establish live rendering of subagent routing lines. diff --git a/tests/integration/README.md b/tests/integration/README.md index 38a6a8990..45aecede6 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1029,6 +1029,6 @@ Local Claude mod confirmation replaces the old saved-control-file/assistant-turn 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 mod component checks cover flag-gated routing notices, routing records, and +Subagent mod component checks cover routing records, suppression of plain-text notices, and packaging the subagent renderer beside the native status/toggle entry point. They do not establish live rendering of subagent routing lines. diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index ac664c0de..7bac1753b 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -13,8 +13,8 @@ import pytest -from ucode import constants, gateway_proxy, managed_files from ucode import databricks as db_mod +from ucode import gateway_proxy, managed_files from ucode.agents import LaunchOptions, claude from ucode.smart_routing import claude_routing, v2 from ucode.state import MANAGED_OVERLAY_KEY @@ -71,9 +71,7 @@ def test_supported_version(self, monkeypatch, version): assert claude.minimum_version_error() is None - @pytest.mark.parametrize("mods_enabled", ["0", "1"]) - def test_older_version_requires_update(self, monkeypatch, mods_enabled): - monkeypatch.setenv("ENABLE_CLAUDE_CODE_MODS", mods_enabled) + def test_older_version_requires_update(self, monkeypatch): monkeypatch.setattr(claude, "agent_version", lambda _binary: "2.1.289") assert claude.minimum_version_error() == ( @@ -86,16 +84,6 @@ def test_unknown_version_does_not_block(self, monkeypatch): assert claude.minimum_version_error() is None -class TestClaudeCodeModsFlag: - def test_enabled_for_one_or_true_case_insensitively(self): - assert constants.claude_code_mods_enabled({"ENABLE_CLAUDE_CODE_MODS": "1"}) - assert constants.claude_code_mods_enabled({"ENABLE_CLAUDE_CODE_MODS": "true"}) - assert constants.claude_code_mods_enabled({"ENABLE_CLAUDE_CODE_MODS": "TRUE"}) - assert not constants.claude_code_mods_enabled({"ENABLE_CLAUDE_CODE_MODS": "yes"}) - assert not constants.claude_code_mods_enabled({"ENABLE_CLAUDE_CODE_MODS": "0"}) - assert not constants.claude_code_mods_enabled({}) - - class TestRenderOverlay: def test_long_context_suffix_supports_major_only_claude_versions(self): assert claude._maybe_add_1m_suffix("system.ai.claude-sonnet-5") == ( diff --git a/tests/test_claude_smart_routing_v2.py b/tests/test_claude_smart_routing_v2.py index 190b5aca2..32659bf08 100644 --- a/tests/test_claude_smart_routing_v2.py +++ b/tests/test_claude_smart_routing_v2.py @@ -12,7 +12,6 @@ import pytest -from ucode import mods from ucode.agents import LaunchOptions, claude from ucode.constants import AGENT_CLAUDE from ucode.databricks import AnthropicModelCatalog @@ -335,6 +334,8 @@ def fake_run(argv, **kwargs): "claude-sonnet-5": "system.ai.claude-sonnet-5", } assert captured["settings"]["env"][v2.ENABLE_SMART_ROUTING_ENV_VAR] == "1" + hook = captured["settings"]["hooks"]["PreToolUse"][0]["hooks"][0] + assert "statusMessage" not in hook for key in (v2.SESSION_ENV_VAR, v2.SESSION_PYTHON_ENV_VAR): assert captured["settings"]["env"][key] == os.environ[key] assert claude_hooks.FIRST_PROMPT_SOCKET_ENV in captured["settings"]["env"] @@ -659,7 +660,6 @@ def test_routes_agent_prompt_with_initialized_model_menu(self, tmp_path, monkeyp monkeypatch.setenv("ENABLE_SMART_ROUTER_ORCHESTRATOR", "0") captured = {} decisions_path = tmp_path / "decisions.jsonl" - monkeypatch.delenv("ENABLE_CLAUDE_CODE_MODS", raising=False) # plain-text notice path monkeypatch.setattr(v2.claude_routing, "DECISIONS_PATH", decisions_path) def fake_select(workspace, token, task, route_options, resolve, **kwargs): @@ -710,20 +710,12 @@ def fake_select(workspace, token, task, route_options, resolve, **kwargs): assert updated_input["subagent_type"] == v2._routed_claude_agent_name( "system.ai.claude-opus-4-8" ) - expected_message = ( - "\n┌───────────────────────────────────────────────────────────────────────────┐\n" - "│ Using Unity Gateway Smart Router - Subagent │\n" - "│ Subagent : Explore │\n" - "│ Selected Model : system.ai.claude-opus-4-8 │\n" - "└───────────────────────────────────────────────────────────────────────────┘" - ) - assert output["systemMessage"] == expected_message - assert output["hookSpecificOutput"]["permissionDecisionReason"] == expected_message + assert "systemMessage" not in output + assert "permissionDecisionReason" not in output["hookSpecificOutput"] decision_record = json.loads(decisions_path.read_text()) assert decision_record["requested_model"] == "system.ai.claude-opus-4-8" - def test_mods_suppress_notice_and_record_routing_for_the_mod(self, tmp_path, monkeypatch): - monkeypatch.setenv("ENABLE_CLAUDE_CODE_MODS", "1") + def test_records_routing_for_the_mod_without_plain_text_notice(self, tmp_path, monkeypatch): monkeypatch.setenv(v2.SESSION_ENV_VAR, str(tmp_path / "env.json")) monkeypatch.setattr( routing, @@ -732,7 +724,7 @@ def test_mods_suppress_notice_and_record_routing_for_the_mod(self, tmp_path, mon routing.RoutingDecision( model="system.ai.claude-opus-4-8", raw_model="claude-opus-4-8", - rationale="Routed to Low because its task-only profile is low", + rationale="A focused task with an explicit target", ), None, ), @@ -757,7 +749,7 @@ def test_mods_suppress_notice_and_record_routing_for_the_mod(self, tmp_path, mon records = json.loads((tmp_path / "mod-routing.json").read_text()) assert records["inspect the parser"] == { "model": "system.ai.claude-opus-4-8", - "rationale": "Routed to Low because its task-only profile is low", + "rationale": "A focused task with an explicit target", } def test_subagent_hook_status_label_is_omittable(self): @@ -804,12 +796,16 @@ def test_includes_subagent_routing_mod(self, tmp_path): v2._write_routed_claude_plugin(plugin_dir, ["system.ai.claude-opus-4-8"]) - mod = mods.SMART_ROUTING_UI hooks_dir = plugin_dir / "hooks" hooks = json.loads((hooks_dir / "hooks.json").read_text()) # One hooks module (register.ts) per plugin; the files it imports ride along. assert hooks == {"modules": ["./register.ts"]} - for name in (mod.source, *mod.extra): + for name in ( + "register.ts", + "constants.ts", + "smart-routing-status.ts", + "subagent-routing.ts", + ): assert (hooks_dir / name).is_file() entry = (hooks_dir / "register.ts").read_text() assert "registerStatusBand" in entry and "registerSubagentRouting" in entry From acf9123706149ce35217db67d809a4963713680e Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Wed, 14 Oct 2026 03:19:34 +0000 Subject: [PATCH 08/18] Route Claude subagents through native mod without sidecar --- docs/claude-mod.md | 23 +++- src/ucode/agents/claude_mods/register.ts | 2 +- .../agents/claude_mods/subagent-routing.ts | 103 ++++++++---------- src/ucode/cli.py | 19 +++- src/ucode/smart_routing/claude_hooks.py | 37 ++----- src/ucode/smart_routing/v2.py | 74 +++++++------ tests/README.md | 19 ++-- tests/integration/README.md | 27 +++-- .../test_ug_smart_routing_hooks.py | 69 ++++++++---- .../mod_tests/subagent-routing.test.ts | 80 ++++++++++++++ tests/native_claude/observe.ts | 3 + tests/native_claude/session.py | 9 +- tests/native_claude/test_mod.py | 34 ++++++ tests/test_claude_mod.py | 2 + tests/test_claude_smart_routing_v2.py | 88 ++++++++------- tests/test_claude_windows_smart_routing.py | 4 +- tests/test_cli.py | 49 +++++++++ 17 files changed, 437 insertions(+), 205 deletions(-) create mode 100644 tests/native_claude/mod_tests/subagent-routing.test.ts diff --git a/docs/claude-mod.md b/docs/claude-mod.md index dd7eff067..930317ed0 100644 --- a/docs/claude-mod.md +++ b/docs/claude-mod.md @@ -9,9 +9,20 @@ Every routed launch includes the status band above the prompt. Both full and sub routing launches use this path, and the existing temporary-directory lifecycle cleans up the files when Claude exits. -The same entry point renders subagent routing details under Agent/Task rows. -Python hooks write the model and rationale to the session's `mod-routing.json` -and suppress the plain-text notice and spinner label. +The same entry point intercepts Agent/Task calls through native `tool.call` hooks. +It runs `ug claude-router-hook route-subagent-mod` with a JSON payload on stdin. +Python uses the same routing API client as Codex and returns the updated input, +model, and rationale as JSON on stdout. The mod keeps that result in memory by +`tool_use_id` and renders the model and rationale under the matching Agent row. +It writes no routing sidecar file. Disabled routing or an unavailable route leaves +the call unchanged; command failures and invalid responses also keep the original +input and show a diagnostic. Other mods and Claude's permission checks still run. + +Launch settings pass the command's argument list through the native `routeCommand` +plugin option, including the workspace, profile/PAT selection, and offered model IDs. +SessionStart/SubagentStart audit hooks remain; the old routing PreToolUse hook is +removed so the same call cannot be routed twice. UI details last for the loaded +mod's lifetime; a plugin reload does not restore earlier routing annotations. `constants.ts` holds the values the mod shares with UG's CLI and the gateway: the recipe header name, the disabled recipe, the CLI module, agent, and routing flags, and the @@ -42,8 +53,10 @@ routing hooks apply over the environment, so an "off" saved earlier by the CLI o cannot outlast the command. The mod writes no file itself: it runs the existing `--enable-smart-routing`/`--disable-smart-routing` entry point, which validates and replaces that file atomically. Session start restores only the launch baseline, so an override saved -before `/clear` still survives it. The plugin test host provides no process capability, so -this path is covered by the live skill-toggle journey rather than `claude plugin test`. +before `/clear` still survives it. The live skill-toggle journey covers the real CLI write; native component tests +use the test host's process stubs to check the subagent routing protocol and display. +A native process check also runs the real disabled Python CLI and verifies inherited +launch flags and a dummy token; it makes no gateway request. The existing Claude skill-toggle integration journey checks recorded inference headers during its three calculation phases: launch recipe, `DISABLED` after off, diff --git a/src/ucode/agents/claude_mods/register.ts b/src/ucode/agents/claude_mods/register.ts index 1df9dcde6..3be68fd63 100644 --- a/src/ucode/agents/claude_mods/register.ts +++ b/src/ucode/agents/claude_mods/register.ts @@ -108,5 +108,5 @@ export function register(on: On, options: PluginOptions): void { } }); registerStatusBand(on); - registerSubagentRouting(on); + registerSubagentRouting(on, options); } diff --git a/src/ucode/agents/claude_mods/subagent-routing.ts b/src/ucode/agents/claude_mods/subagent-routing.ts index 2d5f53b40..85c08d8f8 100644 --- a/src/ucode/agents/claude_mods/subagent-routing.ts +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -1,75 +1,66 @@ -import type { On } from 'claude-code' +import type { On, PluginOptions } from 'claude-code' -// The subagent-routing concern of ug's smart-routing UI mod (composed by -// register.ts). ug suppresses its plain-text -// subagent routing notice and this stacks a compact block under each Agent row: -// -// ⏺ Agent(map the token refresh flow) -// ↳ explorer claude-sonnet-5 -// Reason: A focused task with an explicit target… -// -// The routed model comes off the Agent row's result (resolvedModel); the subagent -// name and reason come from the router rationale and the tool input, which -// ug's Python subagent hook writes (keyed by task) into a per-session file next to -// UCODE_SESSION_ENV_FILE. +type RoutingRecord = { model: string; rationale: string; name: string } -type RoutingRecord = { model?: string; rationale?: string } - -// Reason text as the plain-text box showed it; only the trailing "Request: " -// echo is dropped, since the task is already named on the Agent row. -function parseReason(text: string | undefined): string { - if (!text) return '' - return text.replace(/[.\s]*Request:.*$/is, '').trim() -} - -// Drop the provider prefix so the model reads compactly (system.ai.X -> X). const shortModel = (model: string): string => model.replace(/^system\.ai\./, '').replace(/^databricks-/, '') -const taskKey = (input: any): string => - String(input?.prompt ?? input?.description ?? input?.subagent_type ?? '') +export const registerSubagentRouting = (on: On, options: PluginOptions): void => { + const records = new Map() -async function readRouting($: any): Promise> { - let file: string | undefined - try { - file = await $.env.get('UCODE_SESSION_ENV_FILE') - } catch {} - if (!file) return {} - const dir = file.slice(0, Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\'))) - try { - const parsed = JSON.parse(await $.fs.read(`${dir}/mod-routing.json`)) - return parsed && typeof parsed === 'object' ? parsed : {} - } catch { - return {} - } -} + on('tool.call', { tool: ['Agent', 'Task'] }, async ($, e, next) => { + const command = options.routeCommand as readonly string[] + if (!command.length) return next(e) + const { tool, tool_use_id, agentId, ...input } = e + let route + try { + const session_id = await $.session.id() + const result = await $.process.run(command, { + stdin: JSON.stringify({ session_id, tool_use_id, tool_name: tool, tool_input: input }), + timeoutMs: 35000, + }) + if (result.exitCode !== 0) throw new Error('Router command failed') + // An empty response means routing is disabled or no model could be selected. + if (result.stdout.trim()) { + route = JSON.parse(result.stdout) + if (typeof route?.model !== 'string' || !route.model || + typeof route.rationale !== 'string' || + typeof route.updatedInput?.subagent_type !== 'string' || !route.updatedInput.subagent_type) { + throw new Error('Invalid routing response') + } + } + } catch { + route = undefined + $.ui.log('Smart Router could not route this subagent; using its original settings.') + } + if (!route) return next(e) + if (tool_use_id) { + records.set(tool_use_id, { + model: route.model, + rationale: route.rationale, + name: typeof input.subagent_type === 'string' ? input.subagent_type : '', + }) + $.ui.invalidate('ui.render') + } + // Preserve Claude's permission checks and every other mod's tool handlers. + return next({ ...route.updatedInput, tool, tool_use_id, agentId }) + }) -export const registerSubagentRouting = (on: On): void => { on('ui.render', { component: 'ToolUse' }, async ($, e, next) => { const original = await next(e) - const props = e.props as any - if (props?.tool !== 'Agent' && props?.tool !== 'Task') return original - - const routing = await readRouting($) - const record = routing[taskKey(props.input)] ?? {} - const model: string | undefined = props.output?.resolvedModel ?? record.model - const reason = parseReason(record.rationale) - const name = String(props.input?.subagent_type ?? '').trim() + const record = records.get(e.requestId) + if (!record) return original const { Box, Text } = $.ui.resolve(e) - // Until the subagent has a routed model, show "routing…" rather than a blank - // row — routing can take a moment and this is the only live progress signal. const header = Box({ flexDirection: 'row', children: [ Text({ dimColor: true, children: [' ↳ '] }), - ...(name ? [Text({ color: 'primary', bold: true, children: [`${name} `] })] : []), - Text({ color: 'primary', children: [model ? shortModel(model) : 'routing…'] }), + ...(record.name ? [Text({ color: 'primary', bold: true, children: [`${record.name} `] })] : []), + Text({ color: 'primary', children: [shortModel(record.model)] }), ], }) - // Stack the Reason on its own line below so neither the model nor the - // rationale gets squeezed/truncated by a shared row. - const children = reason - ? [original, header, Text({ dimColor: true, children: [` Reason: ${reason}`] })] + const children = record.rationale + ? [original, header, Text({ dimColor: true, children: [` Reason: ${record.rationale}`] })] : [original, header] return Box({ flexDirection: 'column', children }) }) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 5c37a064a..09284f6d8 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -139,7 +139,11 @@ from ucode.skills_list import configured_skill_counts_by_agent, list_configured_skills_command from ucode.skills_state import records_for_scope from ucode.smart_routing import v2 as smart_routing_v2 -from ucode.smart_routing.claude_hooks import FIRST_PROMPT_SOCKET_ENV, ROUTE_FIRST_PROMPT_EVENT +from ucode.smart_routing.claude_hooks import ( + FIRST_PROMPT_SOCKET_ENV, + ROUTE_FIRST_PROMPT_EVENT, + ROUTE_SUBAGENT_MOD_EVENT, +) from ucode.smart_routing.session_env import ( effective_environment, session_env_path, @@ -2436,7 +2440,7 @@ def codex_router_hook_cmd( ) ) # When matched is None the harness didn't report the subagent model — - # the PreToolUse hook already injected the routed model, so emit nothing. + # routing already injected the selected model, so emit nothing. return if event != "route-subagent" or not host: return @@ -2537,9 +2541,9 @@ def claude_router_hook_cmd( ) ) # When matched is None the harness didn't report the subagent model — - # the PreToolUse hook already injected the routed model, so emit nothing. + # routing already injected the selected model, so emit nothing. return - if event != "route-subagent" or not host: + if event not in {"route-subagent", ROUTE_SUBAGENT_MOD_EVENT} or not host: return token = os.environ.get("OAUTH_TOKEN") or os.environ.get("DATABRICKS_BEARER") if not token: @@ -2549,7 +2553,12 @@ def claude_router_hook_cmd( token = get_databricks_token(host, profile) except RuntimeError: return - output = smart_routing_v2.route_claude_pre_tool_use( + route_subagent = ( + smart_routing_v2.route_claude_subagent + if event == ROUTE_SUBAGENT_MOD_EVENT + else smart_routing_v2.route_claude_pre_tool_use + ) + output = route_subagent( payload, workspace=host, token=token, diff --git a/src/ucode/smart_routing/claude_hooks.py b/src/ucode/smart_routing/claude_hooks.py index d84541a2a..29c021a24 100644 --- a/src/ucode/smart_routing/claude_hooks.py +++ b/src/ucode/smart_routing/claude_hooks.py @@ -1,10 +1,8 @@ """Claude Code hook configuration for smart subagent routing. Written into ``~/.claude/ucode-settings.json`` under Claude Code's hook events. -The ``PreToolUse`` hook matches the subagent-spawn tool (``Agent``, formerly -``Task``) and rewrites its ``model`` input to the router's pick; ``SessionStart`` -and ``SubagentStart`` drive the canary/audit trail. Mirrors ``codex_hooks`` but -emits Claude Code's settings.json hook shape. +The native Claude mod routes subagent calls. ``SessionStart`` and +``SubagentStart`` hooks drive the canary/audit trail. """ from __future__ import annotations @@ -16,19 +14,14 @@ ROUTING_HOOK_COMMAND_MARKER = "claude-router-hook" ROUTE_FIRST_PROMPT_EVENT = "route-first-prompt" +ROUTE_SUBAGENT_MOD_EVENT = "route-subagent-mod" FIRST_PROMPT_HOOK_MARKER = f"{ROUTING_HOOK_COMMAND_MARKER} {ROUTE_FIRST_PROMPT_EVENT}" FIRST_PROMPT_SOCKET_ENV = "UCODE_CLAUDE_V2_SOCKET" -def sync_smart_routing_hooks( - doc: dict, state: dict, *, enabled: bool, subagent_status: str | None = "Routing subagent model" -) -> None: - """Synchronize ucode-managed routing hooks in a Claude settings document. - - ``subagent_status`` is the spinner label shown while the subagent-spawn hook - runs; pass ``None`` to show no label (e.g. when a mod renders the routing). - """ - groups = _routing_hook_groups(state, subagent_status=subagent_status) if enabled else {} +def sync_smart_routing_hooks(doc: dict, state: dict, *, enabled: bool) -> None: + """Synchronize ucode-managed lifecycle hooks, removing legacy routing hooks.""" + groups = _routing_hook_groups(state) if enabled else {} hooks.sync_managed_hooks(doc, ROUTING_HOOK_COMMAND_MARKER, groups) @@ -54,17 +47,10 @@ def sync_first_prompt_hook(doc: dict, executable: str) -> None: hooks.sync_managed_hooks(doc, FIRST_PROMPT_HOOK_MARKER, groups) -def _routing_hook_groups(state: dict, *, subagent_status: str | None) -> dict[str, list[dict]]: - route_argv = _routing_hook_argv(state, "route-subagent") - session_argv = _routing_hook_argv(state, "session-start") - subagent_argv = _routing_hook_argv(state, "record-subagent") +def _routing_hook_groups(state: dict) -> dict[str, list[dict]]: + session_argv = routing_hook_argv(state, "session-start") + subagent_argv = routing_hook_argv(state, "record-subagent") return { - "PreToolUse": [ - { - "matcher": "Agent|Task", - "hooks": [_routing_command_hook(route_argv, status=subagent_status)], - } - ], "SessionStart": [ { "matcher": "startup|resume|clear", @@ -79,7 +65,8 @@ def _routing_hook_groups(state: dict, *, subagent_status: str | None) -> dict[st } -def _routing_hook_argv(state: dict, event: str) -> list[str]: +def routing_hook_argv(state: dict, event: str) -> list[str]: + """Build the shared CLI command for a lifecycle hook or native mod request.""" workspace = str(state.get("workspace") or "") argv = [ build_auth_token_argv(workspace, state.get("profile"), use_pat=bool(state.get("use_pat")))[ @@ -88,7 +75,7 @@ def _routing_hook_argv(state: dict, event: str) -> list[str]: ROUTING_HOOK_COMMAND_MARKER, event, ] - if event != "route-subagent": + if event not in {"route-subagent", ROUTE_SUBAGENT_MOD_EVENT}: return argv argv += ["--host", workspace] profile = state.get("profile") diff --git a/src/ucode/smart_routing/v2.py b/src/ucode/smart_routing/v2.py index 1a7cdadfb..825ffa930 100644 --- a/src/ucode/smart_routing/v2.py +++ b/src/ucode/smart_routing/v2.py @@ -60,6 +60,8 @@ from ucode.smart_routing import claude_routing, orchestrator, routing from ucode.smart_routing.claude_hooks import ( FIRST_PROMPT_SOCKET_ENV, + ROUTE_SUBAGENT_MOD_EVENT, + routing_hook_argv, sync_first_prompt_hook, sync_smart_routing_hooks, ) @@ -346,6 +348,7 @@ def _claude_mod_launch_options(env: Mapping[str, str] | None = None) -> dict: # Native plugin options cannot represent null; preserve absent versus empty values. "unset": [key for key in SMART_ROUTING_ENV_KEYS if key not in source], "recipe": source.get(routing.ROUTER_NAME_ENV_VAR, "").strip() or routing.ROUTER_NAME, + "routeCommand": [], } @@ -359,6 +362,13 @@ def _write_routed_claude_plugin(plugin_dir: Path, model_ids: list[str]) -> None: "description": "Launch-scoped agents for Unity Gateway smart routing.", "author": {"name": "Databricks"}, "userConfig": { + "routeCommand": { + "type": "string", + "multiple": True, + "default": [], + "title": "Subagent router", + "description": "CLI argument list for a subagent routing decision", + }, **{ key: {"type": "string", "title": key, "description": "Original routing flag"} for key in SMART_ROUTING_ENV_KEYS @@ -466,7 +476,7 @@ def _route_print_mode_prompt( return model_name(_unwrapped_claude_model_id(decision.model)) -def route_claude_pre_tool_use( +def route_claude_subagent( payload: dict, *, workspace: str, @@ -474,7 +484,7 @@ def route_claude_pre_tool_use( available_models: list[str], audit_decision: bool = False, ) -> dict | None: - """Route a Claude Agent call through a transient exact-model agent definition.""" + """Return a routed Agent input and UI details using the shared Python client.""" route = routing.resolve_spawn_route( payload, is_spawn_agent=claude_routing.is_spawn_agent_tool, @@ -498,35 +508,40 @@ def route_claude_pre_tool_use( **{key: value for key, value in route.tool_input.items() if key != "model"}, "subagent_type": _routed_claude_agent_name(route.routed_model), } - _record_mod_routing(route.task, route.routed_model, route.decision.rationale) + return { + "updatedInput": updated_input, + "model": route.routed_model, + "rationale": route.decision.rationale, + } + + +def route_claude_pre_tool_use( + payload: dict, + *, + workspace: str, + token: str, + available_models: list[str], + audit_decision: bool = False, +) -> dict | None: + """Keep the legacy CLI hook contract for previously configured callers.""" + route = route_claude_subagent( + payload, + workspace=workspace, + token=token, + available_models=available_models, + audit_decision=audit_decision, + ) + if route is None: + return None return { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow", - "updatedInput": updated_input, + "updatedInput": route["updatedInput"], } } -def _record_mod_routing(task: str, model: str, rationale: str) -> None: - """Write a routing record the subagent-routing mod reads (keyed by task). - - Lives next to the session env file so the mod can find it from - UCODE_SESSION_ENV_FILE; best-effort, never blocks routing. - """ - session_file = os.environ.get(SESSION_ENV_VAR) - if not session_file: - return - path = Path(session_file).with_name("mod-routing.json") - try: - existing = read_json_safe(path) - records = existing if isinstance(existing, dict) else {} - records[task] = {"model": model, "rationale": rationale} - config_io.atomic_write_json(path, records) - except OSError: - pass - - def _is_claude_target_model(value: object) -> bool: if not isinstance(value, str): return False @@ -676,13 +691,7 @@ def launch_claude( **state, "claude_models": {str(index): model for index, model in enumerate(model_ids)}, } - # The spinner notice is redundant when the mod renders the routing itself. - sync_smart_routing_hooks( - settings, - routing_state, - enabled=True, - subagent_status=None, - ) + sync_smart_routing_hooks(settings, routing_state, enabled=True) if route_first_prompt: sync_first_prompt_hook(settings, hook_executable) orchestrator.sync_hooks(settings, agent="claude") @@ -710,7 +719,10 @@ def route_prompt(prompt: str) -> claude_pty.FirstPromptRoute: env[SESSION_PYTHON_ENV_VAR] = os.environ[SESSION_PYTHON_ENV_VAR] settings.setdefault("pluginConfigs", {}).setdefault(CLAUDE_ROUTING_PLUGIN_NAME, {})[ "options" - ] = _claude_mod_launch_options({**resolve_environment(agent=AGENT_CLAUDE), **env}) + ] = { + **_claude_mod_launch_options({**resolve_environment(agent=AGENT_CLAUDE), **env}), + "routeCommand": routing_hook_argv(routing_state, ROUTE_SUBAGENT_MOD_EVENT), + } try: write_json_file(settings_path, settings) _write_routed_claude_plugin(plugin_dir, model_ids) diff --git a/tests/README.md b/tests/README.md index 24d8dcb50..b25764491 100644 --- a/tests/README.md +++ b/tests/README.md @@ -310,8 +310,8 @@ integration utilities; only CUJ-specific evidence correlation stays in a test fi | `test_ug_codex_app_help`, `test_ug_codex_app_server_help`, `test_ug_codex_exec_help`, `test_ug_codex_mcp_help` | Request subcommand help with legacy smart 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` with legacy smart routing off/on | Real Codex parser error and status preserved | | `test_ug_codex_app_server_client_initializes` | Connect a stdio client, direct/`--` separator, with legacy smart 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 legacy subagent routing or each supported routing preset | 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_router_skill_toggles_claude_subagent_routing`, `test_smart_router_skill_toggles_codex_subagent_routing` | Configure, launch a real subagent-only TUI with legacy routing-only/orchestration flags or the three subagent-only presets, then spawn tagged children while invoking the installed Smart Router skill to switch routing on -> off -> on in the same session | Routing-only cases install `smart-router`; orchestration cases also install `smart-router-orchestrator`; all three native children complete; only routing-enabled phases show the subagent banner and produce a live routing decision correlated with the child; Claude's recorded task inference headers are launch recipe -> DISABLED -> launch recipe and receive successful gateway responses; opted-in banners include `[orchestrator on]`; no first-prompt routing wrapper; the customer full-mode preset is intentionally outside this journey; normal exit | +| `test_smart_routing_claude_route_subagent_hook`, `test_smart_routing_codex_route_subagent_hook` | Pipe a native spawn payload to the installed Claude mod command or Codex PreToolUse hook with legacy subagent routing or each supported routing preset | Claude returns model/rationale/updatedInput JSON; Codex returns an allow-hook decision; 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_router_skill_toggles_claude_subagent_routing`, `test_smart_router_skill_toggles_codex_subagent_routing` | Configure, launch a real subagent-only TUI with legacy routing-only/orchestration flags or the three subagent-only presets, then spawn tagged children while invoking the installed Smart Router skill to switch routing on -> off -> on in the same session | Routing-only cases install `smart-router`; orchestration cases also install `smart-router-orchestrator`; all three native children complete; only routing-enabled phases show a native model/rationale row (Claude) or subagent banner (Codex) and produce a live routing decision correlated with the child; Claude's recorded task inference headers are launch recipe -> DISABLED -> launch recipe and receive successful gateway responses; opted-in Codex banners include `[orchestrator on]`; no first-prompt routing wrapper; the customer full-mode preset is intentionally outside this journey; 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_cleans_stale_skills_mcp_on_workspace_switch` | Configure the first workspace, register its skills MCP, switch to a second real workspace, and use Claude | Old registration removed from Claude and the new workspace state; old workspace bucket preserved; repeat configure stays clean; real file task completes on the second workspace | | `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 | @@ -502,8 +502,8 @@ Native launch-option component tests (`native_claude/test_mod.py`) check two rea processes with different resolved routing baselines. Python cases cover Claude-specific presets, unknown selectors, and absent versus empty flags. These checks do not establish gateway/TUI coverage. -The plugin test host runs without a process capability, so the mod's session-control call -is covered by the live skill-toggle journey rather than `claude plugin test`. No component +The real session-control process call is covered by the live skill-toggle journey; +native component tests stub the subagent routing process protocol. No component check asserts that the launch paths withdraw the skills. `integration/test_ug_smart_routing_hooks.py` checks recipe headers in its existing @@ -517,6 +517,11 @@ Local Claude mod confirmation replaces the old saved-control-file/assistant-turn 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 mod component checks cover routing records, suppression of plain-text notices, and -packaging the subagent renderer beside the native status/toggle entry point. They do -not establish live rendering of subagent routing lines. +Subagent mod component checks cover the Python JSON routing response without file writes, +replacement of the legacy routing hook, launch command arguments, and packaging the +renderer beside the native status/toggle entry point. Native mod tests cover routing +input/output, per-tool-call rendering, and fallback for disabled/failed routing using +process stubs. A separate native component check runs the real disabled Python CLI +and verifies inherited routing flags and a dummy auth token, without gateway traffic. +The existing live Claude skill-toggle journey exercises the mod's real +Python routing command; component tests do not establish live gateway/TUI coverage. diff --git a/tests/integration/README.md b/tests/integration/README.md index 45aecede6..313ecb4b3 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -355,11 +355,12 @@ 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. The route-subagent hook journeys drive the real -installed hook commands with a harness-shaped payload against the live router. The +banner journeys below for both agents. The subagent routing command journeys drive the real +installed Claude mod command and Codex hook with native payloads against the live router. The subagent-only TUI journeys additionally spawn three native children in one real session, invoke the installed Smart Router skill to turn routing off and back on, and require the -subagent banner plus a correlated live decision only while routing is enabled. Plugin-refresh +native model/rationale row (Claude) or subagent banner (Codex), plus a correlated +live decision only while routing is enabled. Plugin-refresh survival, native daemon/background dispatch, interactive explicit-model bypass, and dedicated smart-routing CI shards remain deferred. Plugin generation and launch arguments are covered by component tests in `../test_claude_smart_routing_v2.py`, not by a live registration journey. @@ -378,8 +379,9 @@ a new CLI confirmation in native tool-result records, and a new assistant answer skill invocation. Collapsed terminal output is allowed; the answer need not repeat the CLI's exact wording. Each following child still verifies whether a routing decision occurred. -With orchestration enabled through legacy flags or a preset, both routed children -must show `[orchestrator on]` in their own subagent banner. +With orchestration enabled through legacy flags or a preset, both routed Codex children +must show `[orchestrator on]` in their own subagent banner. Claude shows the selected +model and rationale from each child's audited decision. Their off-phase child is an explicit user-requested delegation; these journeys do not establish automatic orchestration behavior. Root-only activation, compaction, retained skills in ineligible sessions, @@ -1014,8 +1016,8 @@ CUJ 4's preset journeys check Claude's visible ON/OFF status band and routing sc before work and after off/on toggles. Checks ignore terminal styling and trailing UI controls; offline cases reject stale states, wrong scope, and command confirmations. -The plugin test host runs without a process capability, so the mod's session-control call -is covered by the live skill-toggle journey rather than `claude plugin test`. No component +The real session-control process call is covered by the live skill-toggle journey; +native component tests stub the subagent routing process protocol. No component check asserts that the launch paths withdraw the skills. `integration/test_ug_smart_routing_hooks.py` checks recipe headers in its existing @@ -1029,6 +1031,11 @@ Local Claude mod confirmation replaces the old saved-control-file/assistant-turn 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 mod component checks cover routing records, suppression of plain-text notices, and -packaging the subagent renderer beside the native status/toggle entry point. They do -not establish live rendering of subagent routing lines. +Subagent mod component checks cover the Python JSON routing response without file writes, +replacement of the legacy routing hook, launch command arguments, and packaging the +renderer beside the native status/toggle entry point. Native mod tests cover routing +input/output, per-tool-call rendering, and fallback for disabled/failed routing using +process stubs. A separate native component check runs the real disabled Python CLI +and verifies inherited routing flags and a dummy auth token, without gateway traffic. +The existing live Claude skill-toggle journey exercises the mod's real +Python routing command; component tests do not establish live gateway/TUI coverage. diff --git a/tests/integration/test_ug_smart_routing_hooks.py b/tests/integration/test_ug_smart_routing_hooks.py index 49c1bb822..a26d5314a 100644 --- a/tests/integration/test_ug_smart_routing_hooks.py +++ b/tests/integration/test_ug_smart_routing_hooks.py @@ -1,8 +1,8 @@ """CUJs for subagent-only routing against the live workspace router. -The agent harness invokes ``ug claude-router-hook route-subagent`` / -``ug codex-router-hook route-subagent`` on its PreToolUse event with a JSON payload on -stdin. These journeys drive the real installed hook commands through that stdin contract, +Claude's native mod invokes ``ug claude-router-hook route-subagent-mod``; Codex's +PreToolUse hook invokes ``ug codex-router-hook route-subagent``. Both send JSON on +stdin. These journeys drive the real installed commands through that stdin contract, so the routing decision, response shape, and audit trail are asserted without relying on an agent choosing to spawn a subagent. The TUI journeys additionally invoke the installed Smart Router skill and spawn real children before and after its session-local toggles. @@ -61,10 +61,18 @@ def _routing_decisions(session, agent: str) -> list[dict]: return read_jsonl(session.home / ".ucode" / f"{agent}-smart-routing-decisions.jsonl") -def _routing_banner_for_task(screen: str, marker: str, *, orchestrator_on: bool) -> bool: +def _routing_display_for_task( + screen: str, marker: str, *, agent: str, orchestrator_on: bool +) -> bool: """Whether the rendered router panel belongs to this uniquely tagged task.""" lines = screen.splitlines() for index, line in enumerate(lines): + if agent == "claude": + if "↳" in line and marker in "".join( + "\n".join(lines[max(0, index - 2) : index]).split() + ): + return True + continue if SMART_ROUTING_SUBAGENT_NOTICE not in line: continue panel = [] @@ -96,12 +104,13 @@ def _run_calculation( if routed: tui.wait_for( - lambda screen: _routing_banner_for_task( + lambda screen: _routing_display_for_task( screen, task.marker, + agent=agent, orchestrator_on=orchestration_enabled, ), - f"the Smart Router subagent banner for {task.marker}", + f"the Smart Router subagent display for {task.marker}", timeout=120, ) tui.wait_for_task(task, timeout=180) @@ -111,15 +120,29 @@ def _run_calculation( after = _routing_decisions(session, agent) new_decisions = after[len(before) :] if not routed: - assert not _routing_banner_for_task(tui.visible, task.marker, orchestrator_on=False), ( - tui.visible - ) + assert not _routing_display_for_task( + tui.visible, task.marker, agent=agent, orchestrator_on=False + ), tui.visible assert not new_decisions, new_decisions return task assert len(new_decisions) == 1, new_decisions decision = new_decisions[0] assert task.marker in decision.get("task_name", ""), decision + if agent == "claude": + model = decision["requested_model"].removeprefix("system.ai.").removeprefix("databricks-") + rationale = decision["rationale"] + tui.wait_for( + lambda screen: ( + model in "".join(screen.split()) + and ( + not rationale + or "".join(f"Reason: {rationale}".split()) in "".join(screen.split()) + ) + ), + f"the selected model and rationale for {task.marker}", + timeout=30, + ) assert_subagent_routed( session, agent, @@ -220,13 +243,13 @@ def toggled(_screen): def test_smart_routing_claude_route_subagent_hook( live_session, workspace, SMART_ROUTER_CONFIG_VERSION ): - """Scenario: legacy subagent routing or each supported selector makes Claude Code fire - PreToolUse for an Agent spawn, piping the payload to ``ug claude-router-hook route-subagent``. + """Scenario: the Claude mod's routing CLI receives an Agent call under legacy + subagent routing or each supported selector. - Expected: the hook allows the call against the real workspace router, drops the - requested model in favor of a ``ucode-route-`` agent definition while preserving the - task text, and audits one decision naming an offered model for the session. Only the - hook contract is asserted; no agent decides to spawn. + Expected: the shared Python client calls the real router and returns model, + rationale, and an updated input naming a routed agent while preserving the task. + One decision is audited for the session. This checks the mod's CLI protocol; + no native mod or agent decides to spawn. """ session = live_session if SMART_ROUTER_CONFIG_VERSION is None: @@ -245,7 +268,7 @@ def test_smart_routing_claude_route_subagent_hook( } result = session.run( "claude-router-hook", - "route-subagent", + "route-subagent-mod", "--host", workspace, *(arg for model in CLAUDE_MODELS for arg in ("--model", model)), @@ -253,11 +276,9 @@ def test_smart_routing_claude_route_subagent_hook( timeout=60, ) output = json.loads(result.stdout) - hook = output["hookSpecificOutput"] - assert hook["hookEventName"] == "PreToolUse", output - assert hook["permissionDecision"] == "allow", output - assert SMART_ROUTING_SUBAGENT_NOTICE in output["systemMessage"], output - updated = hook["updatedInput"] + assert output["model"] in CLAUDE_MODELS, output + assert isinstance(output["rationale"], str), output + updated = output["updatedInput"] assert "model" not in updated, updated assert updated["subagent_type"].startswith("ug-smart-router:ucode-route-"), updated assert updated["prompt"] == payload["tool_input"]["prompt"], updated @@ -273,6 +294,9 @@ def test_smart_routing_claude_route_subagent_hook( assert row["task_name"] == payload["tool_input"]["prompt"], row assert row["requested_model"] in CLAUDE_MODELS, row + assert row["requested_model"] == output["model"], row + assert row["rationale"] == output["rationale"], row + @pytest.mark.live @pytest.mark.codex @@ -380,10 +404,9 @@ def test_smart_router_skill_toggles_claude_subagent_routing( All three uniquely tagged calculations complete in native parent and child sessions; their recorded inference requests carry the launch recipe, DISABLED, then the launch recipe again, and receive non-empty HTTP 200 responses (including verified native - compatibility retries). Only the first and third show the subagent-routing banner + compatibility retries). Only the first and third show native model/rationale rows and produce live gateway decisions correlated with those children. Claude's native task view reports no running tasks before /exit. No first-prompt routing wrapper starts. - With orchestration enabled, both routed banners must include [orchestrator on]. """ session = live_session session.env["TMPDIR"] = str(tmp_path) diff --git a/tests/native_claude/mod_tests/subagent-routing.test.ts b/tests/native_claude/mod_tests/subagent-routing.test.ts new file mode 100644 index 000000000..7c488a0eb --- /dev/null +++ b/tests/native_claude/mod_tests/subagent-routing.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from 'claude-code/testing'; + +const command = ['/bin/ug', 'claude-router-hook', 'route-subagent-mod']; +const originalInput = { prompt: 'inspect the parser', description: 'Inspect parser', subagent_type: 'Explore', model: 'sonnet' }; + +const row = (id: string) => ({ + plugin: 'ug-smart-router', + component: 'ToolUse' as const, + surface: 'terminal' as const, + requestId: id, + viewport: { columns: 100, rows: 30 }, + props: { tool_use_id: id, tool: 'Agent', input: originalInput, isRunning: true, isErrored: false, isInterrupted: false }, +}); + +test('routing preserves distinct results for identical tasks and forwards each call once', async ($, on) => { + const calls: any[] = []; + on('session.id', () => ({ value: 'session-1' })); + on('process.run', ($, e) => { + expect(e.argv).toEqual(command); + expect(e.init.timeoutMs).toBe(35000); + const payload = JSON.parse(e.init.stdin); + expect(payload.session_id).toBe('session-1'); + expect(payload.tool_input).toEqual(originalInput); + const model = payload.tool_use_id === 'call-a' ? 'system.ai.claude-sonnet-5' : 'system.ai.claude-opus-4-8'; + return { value: { exitCode: 0, stderr: '', stdout: JSON.stringify({ + model, rationale: `Selected for ${payload.tool_use_id}`, + updatedInput: { prompt: originalInput.prompt, description: originalInput.description, subagent_type: `ug-smart-router:${payload.tool_use_id}` }, + }) } }; + }); + on('tool.call', ($, e) => { calls.push(e); return { result: 'ok' }; }); + on('ui.render', () => ({ type: 'Text', props: {}, children: ['original Agent row'] })); + + await $.tool.call({ tool: 'Agent', tool_use_id: 'call-a', ...originalInput }); + await $.tool.call({ tool: 'Agent', tool_use_id: 'call-b', ...originalInput }); + expect(calls.length).toBe(2); + expect(calls[0].tool_use_id).toBe('call-a'); + expect(calls[0].subagent_type).toBe('ug-smart-router:call-a'); + expect(calls[0].model).toBeUndefined(); + expect(calls[1].subagent_type).toBe('ug-smart-router:call-b'); + + const a = await $.ui.mount(row('call-a')); + expect(await a.find({ type: 'Text', text: 'original Agent row' })).toBeDefined(); + expect(await a.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); + expect(await a.find({ type: 'Text', text: ' Reason: Selected for call-a' })).toBeDefined(); + expect(await a.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeUndefined(); + await a.unmount(); + const b = await $.ui.mount(row('call-b')); + expect(await b.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeDefined(); + expect(await b.find({ type: 'Text', text: ' Reason: Selected for call-b' })).toBeDefined(); + await b.unmount(); +}); + +for (const [name, result, warningCount] of [ + ['disabled', { value: { exitCode: 0, stdout: '', stderr: '' } }, 0], + ['command failure', { value: { exitCode: 1, stdout: '', stderr: 'failure' } }, 1], + ['invalid JSON', { value: { exitCode: 0, stdout: 'invalid', stderr: '' } }, 1], + ['invalid response', { value: { exitCode: 0, stdout: '{}', stderr: '' } }, 1], + ['process unavailable', { deny: 'process unavailable' }, 1], +] as const) { + test(`${name} forwards the original call without a routing annotation`, async ($, on) => { + const calls: any[] = []; + const warnings: string[] = []; + on('session.id', () => ({ value: 'session-1' })); + on('process.run', () => result); + on('ui.log', ($, e) => { warnings.push(e.text); return { value: undefined }; }); + on('tool.call', ($, e) => { calls.push(e); return { deny: 'user policy' }; }); + on('ui.render', () => ({ type: 'Text', props: {}, children: ['original Agent row'] })); + + const answer = await $.tool.call({ tool: 'Agent', tool_use_id: 'call-a', ...originalInput }); + expect(answer).toEqual({ deny: 'user policy' }); + expect(calls.length).toBe(1); + expect(calls[0].subagent_type).toBe(originalInput.subagent_type); + expect(calls[0].model).toBe(originalInput.model); + expect(warnings.length).toBe(warningCount); + const ui = await $.ui.mount(row('call-a')); + expect(await ui.find({ type: 'Text', text: 'original Agent row' })).toBeDefined(); + expect(await ui.find({ type: 'Text', text: /Reason:/ })).toBeUndefined(); + await ui.unmount(); + }); +} diff --git a/tests/native_claude/observe.ts b/tests/native_claude/observe.ts index 5031c6579..9b2491b53 100644 --- a/tests/native_claude/observe.ts +++ b/tests/native_claude/observe.ts @@ -9,6 +9,9 @@ export const register: Register = (on, options) => { }); on('command.run', {command: 'routing-probe'}, async ($) => ({text: JSON.stringify({ revision: 'initial', options, + route: (options.routeCommand as readonly string[]).length + ? await $.process.run(options.routeCommand as readonly string[], {stdin: '{}', timeoutMs: 10000}) + : null, environment: { ENABLE_SMART_ROUTING_V2: await $.env.get('ENABLE_SMART_ROUTING_V2') ?? null, ENABLE_SMART_ROUTING_SUBAGENT_ONLY: await $.env.get('ENABLE_SMART_ROUTING_SUBAGENT_ONLY') ?? null, diff --git a/tests/native_claude/session.py b/tests/native_claude/session.py index b9b85bdca..3c789eec9 100644 --- a/tests/native_claude/session.py +++ b/tests/native_claude/session.py @@ -19,7 +19,7 @@ class Session: - def __init__(self, binary, root, env, api="http://127.0.0.1:1"): + def __init__(self, binary, root, env, api="http://127.0.0.1:1", route_command=()): self.root = root root.mkdir() self.plugin = root / "plugin" @@ -37,7 +37,12 @@ def __init__(self, binary, root, env, api="http://127.0.0.1:1"): "CLAUDE_CODE_EXTRA_BODY": '{"caller_field":"keep"}', }, "pluginConfigs": { - CLAUDE_ROUTING_PLUGIN_NAME: {"options": _claude_mod_launch_options(env)} + CLAUDE_ROUTING_PLUGIN_NAME: { + "options": { + **_claude_mod_launch_options(env), + "routeCommand": list(route_command), + } + } }, } process_env = { diff --git a/tests/native_claude/test_mod.py b/tests/native_claude/test_mod.py index b1bc3bb10..166db82a4 100644 --- a/tests/native_claude/test_mod.py +++ b/tests/native_claude/test_mod.py @@ -2,9 +2,11 @@ from __future__ import annotations +import json import os import shutil import subprocess +import sys from pathlib import Path import pytest @@ -30,6 +32,14 @@ def native_binary(): def test_mod_validation_and_event_forwarding(native_binary, tmp_path): path = tmp_path / "plugin" _write_routed_claude_plugin(path, []) + manifest = path / ".claude-plugin/plugin.json" + doc = json.loads(manifest.read_text()) + doc["userConfig"]["routeCommand"]["default"] = [ + "/bin/ug", + "claude-router-hook", + "route-subagent-mod", + ] + manifest.write_text(json.dumps(doc)) shutil.copytree(Path(__file__).with_name("mod_tests"), path / "tests") for command in ("validate", "test"): result = subprocess.run( @@ -55,3 +65,27 @@ def test_native_options_are_launch_local(native_binary, tmp_path): assert a.observe()["options"] == _claude_mod_launch_options(env) assert b.observe()["options"] == _claude_mod_launch_options({}) assert not (a.plugin / "hooks/launch.json").exists() + + +def test_native_process_inherits_settings_and_runs_python_cli(native_binary, tmp_path): + # The child checks launch settings before replacing itself with the real disabled router. + script = ( + "import os, sys; " + "assert os.environ['ENABLE_SMART_ROUTING_V2'] == '0'; " + "assert os.environ['OAUTH_TOKEN'] == 'fixture-dummy-token'; " + "os.execv(sys.executable, [sys.executable, '-m', 'ucode.cli', " + "'claude-router-hook', 'route-subagent-mod', '--host', 'https://example.com'])" + ) + with Session( + native_binary, + tmp_path / "process with spaces", + { + "ENABLE_SMART_ROUTING_V2": "0", + "ENABLE_SMART_ROUTING_SUBAGENT_ONLY": "0", + "OAUTH_TOKEN": "fixture-dummy-token", + }, + route_command=[sys.executable, "-c", script], + ) as session: + result = session.observe()["route"] + assert result["exitCode"] == 0, result + assert result["stdout"] == result["stderr"] == "", result diff --git a/tests/test_claude_mod.py b/tests/test_claude_mod.py index 1bc35ba82..bad88bca4 100644 --- a/tests/test_claude_mod.py +++ b/tests/test_claude_mod.py @@ -43,6 +43,7 @@ def test_launch_options_resolve_versions_without_mutating_input(version): **expected, "unset": [], "recipe": "custom_recipe", + "routeCommand": [], } # Taking the baseline must not alter the caller's launch environment. assert env == before @@ -55,4 +56,5 @@ def test_launch_options_preserve_empty_and_absent_values(): "ENABLE_SMART_ROUTER_ORCHESTRATOR": "", "unset": ["ENABLE_SMART_ROUTING_SUBAGENT_ONLY", "ENABLE_SMART_ROUTER_ORCHESTRATOR"], "recipe": "task_v3", + "routeCommand": [], } diff --git a/tests/test_claude_smart_routing_v2.py b/tests/test_claude_smart_routing_v2.py index 32659bf08..35eea4405 100644 --- a/tests/test_claude_smart_routing_v2.py +++ b/tests/test_claude_smart_routing_v2.py @@ -334,8 +334,7 @@ def fake_run(argv, **kwargs): "claude-sonnet-5": "system.ai.claude-sonnet-5", } assert captured["settings"]["env"][v2.ENABLE_SMART_ROUTING_ENV_VAR] == "1" - hook = captured["settings"]["hooks"]["PreToolUse"][0]["hooks"][0] - assert "statusMessage" not in hook + assert "PreToolUse" not in captured["settings"]["hooks"] for key in (v2.SESSION_ENV_VAR, v2.SESSION_PYTHON_ENV_VAR): assert captured["settings"]["env"][key] == os.environ[key] assert claude_hooks.FIRST_PROMPT_SOCKET_ENV in captured["settings"]["env"] @@ -343,16 +342,20 @@ def fake_run(argv, **kwargs): "command" ] assert first_prompt_command == "/bin/ug claude-router-hook route-first-prompt" - route_commands = [ - hook["command"] - for group in captured["settings"]["hooks"]["PreToolUse"] - for hook in group["hooks"] - if "route-subagent" in hook["command"] + command = captured["settings"]["pluginConfigs"][v2.CLAUDE_ROUTING_PLUGIN_NAME]["options"][ + "routeCommand" + ] + assert command == [ + "/bin/ug", + "claude-router-hook", + "route-subagent-mod", + "--host", + "https://example.com", + "--model", + "system.ai.claude-opus-4-8", + "--model", + "system.ai.claude-sonnet-5", ] - assert len(route_commands) == 1 - assert route_commands[0].startswith("/bin/ug claude-router-hook route-subagent ") - assert "--model system.ai.claude-opus-4-8" in route_commands[0] - assert "--model system.ai.claude-sonnet-5" in route_commands[0] assert "modelPicker" not in captured["settings"] assert captured["restored_during_run"] == { "model": "haiku", @@ -444,6 +447,7 @@ def fake_run(_argv, **kwargs): } def test_subagent_only_launch_skips_first_prompt_routing(self, tmp_path, monkeypatch): + monkeypatch.setattr("ucode.databricks.ug_binary", lambda: "/bin/ug") user_settings = tmp_path / "settings.json" user_settings.write_text(json.dumps({"model": "opus"})) monkeypatch.delenv(v2.ENABLE_SMART_ROUTING_ENV_VAR, raising=False) @@ -514,7 +518,18 @@ def send_signal(self, _signal): assert exc.value.code == 4 settings = captured["settings"] assert settings["pluginConfigs"][v2.CLAUDE_ROUTING_PLUGIN_NAME] == { - "options": v2._claude_mod_launch_options({**os.environ, **settings["env"]}) + "options": { + **v2._claude_mod_launch_options({**os.environ, **settings["env"]}), + "routeCommand": [ + "/bin/ug", + "claude-router-hook", + "route-subagent-mod", + "--host", + "https://example.com", + "--model", + "system.ai.claude-opus-4-8", + ], + } } env = settings["env"] assert env[v2.ENABLE_SUBAGENT_ROUTING_ENV_VAR] == "1" @@ -524,7 +539,7 @@ def send_signal(self, _signal): assert claude_hooks.FIRST_PROMPT_SOCKET_ENV not in env # Subagent routing is fully wired; only the first-prompt machinery is absent. assert "UserPromptSubmit" not in settings["hooks"] - assert "route-subagent" in str(settings["hooks"]["PreToolUse"]) + assert "PreToolUse" not in settings["hooks"] assert settings["modelOverrides"] == {"claude-opus-4-8": "system.ai.claude-opus-4-8"} assert captured["plugin_models"] == {"system.ai.claude-opus-4-8"} assert captured["argv"][3:5] == ["--model", "opus"] @@ -715,7 +730,7 @@ def fake_select(workspace, token, task, route_options, resolve, **kwargs): decision_record = json.loads(decisions_path.read_text()) assert decision_record["requested_model"] == "system.ai.claude-opus-4-8" - def test_records_routing_for_the_mod_without_plain_text_notice(self, tmp_path, monkeypatch): + def test_returns_routing_details_to_mod_without_writing_files(self, tmp_path, monkeypatch): monkeypatch.setenv(v2.SESSION_ENV_VAR, str(tmp_path / "env.json")) monkeypatch.setattr( routing, @@ -730,7 +745,7 @@ def test_records_routing_for_the_mod_without_plain_text_notice(self, tmp_path, m ), ) - output = v2.route_claude_pre_tool_use( + output = v2.route_claude_subagent( { "tool_name": "Agent", "tool_input": {"prompt": "inspect the parser", "model": "sonnet"}, @@ -740,36 +755,31 @@ def test_records_routing_for_the_mod_without_plain_text_notice(self, tmp_path, m available_models=["system.ai.claude-opus-4-8", "databricks-claude-sonnet-5"], ) - # The mod renders the routing, so no plain-text notice is shown... - assert "systemMessage" not in output - assert "permissionDecisionReason" not in output["hookSpecificOutput"] - # ...but the model rewrite still happens... - assert "model" not in output["hookSpecificOutput"]["updatedInput"] - # ...and the model + rationale are handed to the mod, keyed by task. - records = json.loads((tmp_path / "mod-routing.json").read_text()) - assert records["inspect the parser"] == { + assert output == { "model": "system.ai.claude-opus-4-8", "rationale": "A focused task with an explicit target", + "updatedInput": { + "prompt": "inspect the parser", + "subagent_type": v2._routed_claude_agent_name("system.ai.claude-opus-4-8"), + }, } + assert not any(path.is_file() for path in tmp_path.rglob("*")) - def test_subagent_hook_status_label_is_omittable(self): - with_label: dict = {} - claude_hooks.sync_smart_routing_hooks( - with_label, - {"workspace": "https://x"}, - enabled=True, - subagent_status="Routing subagent model", - ) - silent: dict = {} - claude_hooks.sync_smart_routing_hooks( - silent, {"workspace": "https://x"}, enabled=True, subagent_status=None - ) + def test_mod_replaces_legacy_pretooluse_hook_and_preserves_user_hooks(self): + settings = { + "hooks": { + "PreToolUse": [ + {"hooks": [{"command": "user-policy"}]}, + {"hooks": [{"command": "ug claude-router-hook route-subagent"}]}, + ] + } + } - def pretooluse_hook(doc: dict) -> dict: - return doc["hooks"]["PreToolUse"][0]["hooks"][0] + claude_hooks.sync_smart_routing_hooks(settings, {"workspace": "https://x"}, enabled=True) - assert pretooluse_hook(with_label)["statusMessage"] == "Routing subagent model" - assert "statusMessage" not in pretooluse_hook(silent) + assert settings["hooks"]["PreToolUse"] == [{"hooks": [{"command": "user-policy"}]}] + assert "session-start" in str(settings["hooks"]["SessionStart"]) + assert "record-subagent" in str(settings["hooks"]["SubagentStart"]) def test_writes_routed_agents_as_launch_scoped_plugin(self, tmp_path): plugin_dir = tmp_path / "routing-plugin" diff --git a/tests/test_claude_windows_smart_routing.py b/tests/test_claude_windows_smart_routing.py index b6db0f462..f179b022b 100644 --- a/tests/test_claude_windows_smart_routing.py +++ b/tests/test_claude_windows_smart_routing.py @@ -119,7 +119,9 @@ def send_signal(self, _signal): assert settings["env"][v2.ENABLE_SUBAGENT_ROUTING_ENV_VAR] == "1" assert v2.ENABLE_SMART_ROUTING_ENV_VAR not in settings["env"] assert "UserPromptSubmit" not in settings["hooks"] - assert "route-subagent" in str(settings["hooks"]["PreToolUse"]) + assert "PreToolUse" not in settings["hooks"] + command = settings["pluginConfigs"][v2.CLAUDE_ROUTING_PLUGIN_NAME]["options"]["routeCommand"] + assert command[1:3] == ["claude-router-hook", "route-subagent-mod"] assert settings["modelOverrides"] == {"claude-opus-4-8": "system.ai.claude-opus-4-8"} assert not captured["settings_path"].exists() assert not captured["plugin_dir"].exists() diff --git a/tests/test_cli.py b/tests/test_cli.py index 521bc45d8..8980b767b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1134,6 +1134,55 @@ def test_claude_v2_subagent_hook_uses_v2_router(self, monkeypatch): assert json.loads(result.output) == routed mock_v2_route.assert_called_once() + @pytest.mark.parametrize("enabled", [False, True]) + def test_claude_mod_route_respects_session_controls(self, tmp_path, monkeypatch, enabled): + monkeypatch.setenv("ENABLE_SMART_ROUTING_V2", "1") + session_file = tmp_path / "env.json" + session_file.write_text( + json.dumps( + { + "ENABLE_SMART_ROUTING_V2": "1" if enabled else "0", + "ENABLE_SMART_ROUTING_SUBAGENT_ONLY": "0", + } + ) + ) + routed = { + "model": "system.ai.claude-opus-4-8", + "rationale": "A focused task", + "updatedInput": {"prompt": "fix it", "subagent_type": "ug-smart-router:routed"}, + } + with patch( + "ucode.cli.smart_routing_v2.route_claude_subagent", return_value=routed + ) as route: + result = runner.invoke( + app, + [ + "claude-router-hook", + "route-subagent-mod", + "--host", + "https://example.com", + "--profile", + "my-profile", + "--model", + "system.ai.claude-opus-4-8", + ], + input='{"session_id":"s1","tool_name":"Agent","tool_input":{"prompt":"fix it"}}', + env={"OAUTH_TOKEN": "token", "UCODE_SESSION_ENV_FILE": str(session_file)}, + ) + assert result.exit_code == 0, result.output + if enabled: + assert json.loads(result.output) == routed + route.assert_called_once_with( + {"session_id": "s1", "tool_name": "Agent", "tool_input": {"prompt": "fix it"}}, + workspace="https://example.com", + token="token", + available_models=["system.ai.claude-opus-4-8"], + audit_decision=True, + ) + else: + assert result.output == "" + route.assert_not_called() + class TestManagedConfigLaunchSourceGuard: @pytest.mark.parametrize( From c9ef9ad76b3872f29895260b52cdd2e12f06fa45 Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Wed, 14 Oct 2026 03:25:09 +0000 Subject: [PATCH 09/18] Simplify native Claude subagent routing --- .../agents/claude_mods/subagent-routing.ts | 13 ++++--- src/ucode/cli.py | 15 ++++---- src/ucode/smart_routing/v2.py | 36 +++---------------- tests/README.md | 13 +++---- tests/integration/README.md | 12 +++---- tests/test_claude_smart_routing_v2.py | 6 ++-- tests/test_cli.py | 4 +-- 7 files changed, 33 insertions(+), 66 deletions(-) diff --git a/src/ucode/agents/claude_mods/subagent-routing.ts b/src/ucode/agents/claude_mods/subagent-routing.ts index 85c08d8f8..f75533236 100644 --- a/src/ucode/agents/claude_mods/subagent-routing.ts +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -20,13 +20,12 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => }) if (result.exitCode !== 0) throw new Error('Router command failed') // An empty response means routing is disabled or no model could be selected. - if (result.stdout.trim()) { - route = JSON.parse(result.stdout) - if (typeof route?.model !== 'string' || !route.model || - typeof route.rationale !== 'string' || - typeof route.updatedInput?.subagent_type !== 'string' || !route.updatedInput.subagent_type) { - throw new Error('Invalid routing response') - } + route = result.stdout.trim() ? JSON.parse(result.stdout) : undefined + if (route !== undefined && ( + typeof route?.model !== 'string' || !route.model || + typeof route.rationale !== 'string' || + typeof route.updatedInput?.subagent_type !== 'string' || !route.updatedInput.subagent_type)) { + throw new Error('Invalid routing response') } } catch { route = undefined diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 09284f6d8..5b6f0fbaf 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -2553,12 +2553,7 @@ def claude_router_hook_cmd( token = get_databricks_token(host, profile) except RuntimeError: return - route_subagent = ( - smart_routing_v2.route_claude_subagent - if event == ROUTE_SUBAGENT_MOD_EVENT - else smart_routing_v2.route_claude_pre_tool_use - ) - output = route_subagent( + output = smart_routing_v2.route_claude_subagent( payload, workspace=host, token=token, @@ -2566,6 +2561,14 @@ def claude_router_hook_cmd( audit_decision=True, ) if output is not None: + if event == "route-subagent": + output = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": output["updatedInput"], + } + } sys.stdout.write(json.dumps(output)) diff --git a/src/ucode/smart_routing/v2.py b/src/ucode/smart_routing/v2.py index 825ffa930..698db2afb 100644 --- a/src/ucode/smart_routing/v2.py +++ b/src/ucode/smart_routing/v2.py @@ -504,44 +504,16 @@ def route_claude_subagent( route.decision, route.routed_model, ) - updated_input = { - **{key: value for key, value in route.tool_input.items() if key != "model"}, - "subagent_type": _routed_claude_agent_name(route.routed_model), - } return { - "updatedInput": updated_input, + "updatedInput": { + **{key: value for key, value in route.tool_input.items() if key != "model"}, + "subagent_type": _routed_claude_agent_name(route.routed_model), + }, "model": route.routed_model, "rationale": route.decision.rationale, } -def route_claude_pre_tool_use( - payload: dict, - *, - workspace: str, - token: str, - available_models: list[str], - audit_decision: bool = False, -) -> dict | None: - """Keep the legacy CLI hook contract for previously configured callers.""" - route = route_claude_subagent( - payload, - workspace=workspace, - token=token, - available_models=available_models, - audit_decision=audit_decision, - ) - if route is None: - return None - return { - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "updatedInput": route["updatedInput"], - } - } - - def _is_claude_target_model(value: object) -> bool: if not isinstance(value, str): return False diff --git a/tests/README.md b/tests/README.md index b25764491..098d0a4e3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -517,11 +517,8 @@ Local Claude mod confirmation replaces the old saved-control-file/assistant-turn 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 mod component checks cover the Python JSON routing response without file writes, -replacement of the legacy routing hook, launch command arguments, and packaging the -renderer beside the native status/toggle entry point. Native mod tests cover routing -input/output, per-tool-call rendering, and fallback for disabled/failed routing using -process stubs. A separate native component check runs the real disabled Python CLI -and verifies inherited routing flags and a dummy auth token, without gateway traffic. -The existing live Claude skill-toggle journey exercises the mod's real -Python routing command; component tests do not establish live gateway/TUI coverage. +Subagent component checks cover Python's file-free JSON response, legacy hook replacement, +launch arguments, per-call rendering, and disabled/failed routing. A native check runs the +real disabled CLI with inherited flags and a dummy token. The live Claude skill-toggle +journey exercises the routing command against the gateway; component passes do not prove +live coverage. See [the mod guide](../docs/claude-mod.md). diff --git a/tests/integration/README.md b/tests/integration/README.md index 313ecb4b3..3eea944b7 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1031,11 +1031,7 @@ Local Claude mod confirmation replaces the old saved-control-file/assistant-turn 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 mod component checks cover the Python JSON routing response without file writes, -replacement of the legacy routing hook, launch command arguments, and packaging the -renderer beside the native status/toggle entry point. Native mod tests cover routing -input/output, per-tool-call rendering, and fallback for disabled/failed routing using -process stubs. A separate native component check runs the real disabled Python CLI -and verifies inherited routing flags and a dummy auth token, without gateway traffic. -The existing live Claude skill-toggle journey exercises the mod's real -Python routing command; component tests do not establish live gateway/TUI coverage. +The Claude skill-toggle journey exercises the mod's Python routing command against the +live gateway. Component protocol, rendering, fallback, and subprocess coverage is documented +in [the test overview](../README.md) and [mod guide](../../docs/claude-mod.md); those checks +do not establish live gateway/TUI coverage. diff --git a/tests/test_claude_smart_routing_v2.py b/tests/test_claude_smart_routing_v2.py index 35eea4405..2d464cbe5 100644 --- a/tests/test_claude_smart_routing_v2.py +++ b/tests/test_claude_smart_routing_v2.py @@ -693,7 +693,7 @@ def fake_select(workspace, token, task, route_options, resolve, **kwargs): ) monkeypatch.setattr(routing, "select_route", fake_select) - output = v2.route_claude_pre_tool_use( + output = v2.route_claude_subagent( { "tool_name": "Agent", "tool_input": { @@ -720,13 +720,13 @@ def fake_select(workspace, token, task, route_options, resolve, **kwargs): ("claude-sonnet-5", "claude"), ], } - updated_input = output["hookSpecificOutput"]["updatedInput"] + updated_input = output["updatedInput"] assert "model" not in updated_input assert updated_input["subagent_type"] == v2._routed_claude_agent_name( "system.ai.claude-opus-4-8" ) assert "systemMessage" not in output - assert "permissionDecisionReason" not in output["hookSpecificOutput"] + assert "permissionDecisionReason" not in output decision_record = json.loads(decisions_path.read_text()) assert decision_record["requested_model"] == "system.ai.claude-opus-4-8" diff --git a/tests/test_cli.py b/tests/test_cli.py index 8980b767b..56e07efcc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1112,8 +1112,8 @@ def test_claude_v2_subagent_hook_uses_v2_router(self, monkeypatch): } with ( patch( - "ucode.cli.smart_routing_v2.route_claude_pre_tool_use", - return_value=routed, + "ucode.cli.smart_routing_v2.route_claude_subagent", + return_value={"updatedInput": routed["hookSpecificOutput"]["updatedInput"]}, ) as mock_v2_route, ): result = runner.invoke( From 36bf993f8362fbe7c8332cbe5d9e0f2319dc8f46 Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Fri, 16 Oct 2026 16:32:01 +0000 Subject: [PATCH 10/18] Keep the Smart Router notice for persisted route-subagent hooks Hooks that earlier UG versions persisted still call route-subagent and expect a PreToolUse response. Return main's full response for that event, including the notice naming the dispatched agent, while route-subagent-mod keeps the mod's plain routing details. Co-authored-by: Isaac --- docs/claude-mod.md | 3 ++- src/ucode/cli.py | 11 +++----- src/ucode/smart_routing/v2.py | 37 ++++++++++++++++++++----- tests/test_claude_smart_routing_v2.py | 39 ++++++++++++++++++--------- tests/test_cli.py | 9 +++++-- 5 files changed, 70 insertions(+), 29 deletions(-) diff --git a/docs/claude-mod.md b/docs/claude-mod.md index 0e2da53aa..a63bf97a7 100644 --- a/docs/claude-mod.md +++ b/docs/claude-mod.md @@ -23,7 +23,8 @@ input and show a diagnostic. Other mods and Claude's permission checks still run Launch settings pass the command's argument list through the native `routeCommand` plugin option, including the workspace, profile/PAT selection, and offered model IDs. SessionStart/SubagentStart audit hooks remain; the old routing PreToolUse hook is -removed so the same call cannot be routed twice. UI details last for the loaded +removed so the same call cannot be routed twice. A `route-subagent` hook that an earlier +UG version persisted still gets its PreToolUse response and plain-text notice. UI details last for the loaded mod's lifetime; a plugin reload does not restore earlier routing annotations. `constants.ts` holds the values the mod shares with UG's CLI and the gateway: the recipe diff --git a/src/ucode/cli.py b/src/ucode/cli.py index cbe35bfdf..a52678eae 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -2643,16 +2643,11 @@ def claude_router_hook_cmd( token=token, available_models=model or [], audit_decision=True, + # Hooks persisted by earlier UG versions still call route-subagent and expect a + # PreToolUse response; the mod's route-subagent-mod event takes the plain details. + hook_output=event == "route-subagent", ) if output is not None: - if event == "route-subagent": - output = { - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "updatedInput": output["updatedInput"], - } - } sys.stdout.write(json.dumps(output)) diff --git a/src/ucode/smart_routing/v2.py b/src/ucode/smart_routing/v2.py index 72104bda7..b1fe7ea7f 100644 --- a/src/ucode/smart_routing/v2.py +++ b/src/ucode/smart_routing/v2.py @@ -490,8 +490,13 @@ def route_claude_subagent( token: str, available_models: list[str], audit_decision: bool = False, + hook_output: bool = False, ) -> dict | None: - """Return a routed Agent input and UI details using the shared Python client.""" + """Return a routed Agent input and UI details using the shared Python client. + + ``hook_output`` returns the legacy PreToolUse hook response instead, with its plain-text + notice, for hooks that earlier UG versions persisted in settings files. + """ route = routing.resolve_spawn_route( payload, is_spawn_agent=claude_routing.is_spawn_agent_tool, @@ -511,14 +516,34 @@ def route_claude_subagent( route.decision, route.routed_model, ) + updated_input = { + **{key: value for key, value in route.tool_input.items() if key != "model"}, + "subagent_type": _routed_claude_agent_name(route.routed_model), + } + orchestrator_enabled = orchestrator.enabled(agent=AGENT_CLAUDE) + if hook_output: + agent_name = updated_input["subagent_type"] + if orchestrator_enabled: + agent_name += " [orchestrator on]" + routing_message = claude_routing.SUBAGENT_NOTICE_CONFIG.message( + route.decision, + route.routed_model, + {**updated_input, "subagent_type": agent_name}, + ) + return { + "systemMessage": routing_message, + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": updated_input, + "permissionDecisionReason": routing_message, + }, + } return { - "updatedInput": { - **{key: value for key, value in route.tool_input.items() if key != "model"}, - "subagent_type": _routed_claude_agent_name(route.routed_model), - }, + "updatedInput": updated_input, "model": route.routed_model, "rationale": route.decision.rationale, - "orchestrator": orchestrator.enabled(agent=AGENT_CLAUDE), + "orchestrator": orchestrator_enabled, } diff --git a/tests/test_claude_smart_routing_v2.py b/tests/test_claude_smart_routing_v2.py index b2e3017ad..160dccc71 100644 --- a/tests/test_claude_smart_routing_v2.py +++ b/tests/test_claude_smart_routing_v2.py @@ -804,26 +804,41 @@ def test_routed_agent_details_match_executed_agent( None, ), ) - output = v2.route_claude_subagent( - { - "tool_name": "Agent", - "tool_input": { - "description": "Create a proof file", - "subagent_type": requested_agent, - "prompt": "Write a small proof file.", - }, + payload = { + "tool_name": "Agent", + "tool_input": { + "description": "Create a proof file", + "subagent_type": requested_agent, + "prompt": "Write a small proof file.", }, - workspace="https://example.com", - token="token", - available_models=[selected_model, "system.ai.glm-5-3-flash"], - ) + } + + def route(**kwargs): + return v2.route_claude_subagent( + payload, + workspace="https://example.com", + token="token", + available_models=[selected_model, "system.ai.glm-5-3-flash"], + **kwargs, + ) # The mod labels the row from updatedInput, so it names the dispatched agent. + output = route() assert output["updatedInput"]["subagent_type"] == routed_agent assert output["model"] == selected_model assert requested_agent not in json.dumps(output) assert output["orchestrator"] is (orchestrator_enabled == "1") + # Hooks persisted by earlier UG versions keep their PreToolUse response and notice. + legacy = route(hook_output=True) + hook = legacy["hookSpecificOutput"] + assert hook["permissionDecision"] == "allow" + assert hook["updatedInput"] == output["updatedInput"] + for notice in (legacy["systemMessage"], hook["permissionDecisionReason"]): + assert f"Subagent:{routed_agent}" in "".join(notice.split()) + assert requested_agent not in notice + assert ("[orchestrator on]" in notice) == (orchestrator_enabled == "1") + def test_writes_routed_agents_as_launch_scoped_plugin(self, tmp_path): plugin_dir = tmp_path / "routing-plugin" models = ["databricks-claude-opus-4-8", "system.ai.glm-5-3"] diff --git a/tests/test_cli.py b/tests/test_cli.py index 38f3dbf78..0db439900 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1149,16 +1149,18 @@ def test_codex_subagent_hook_reuses_bearer(self): def test_claude_v2_subagent_hook_uses_v2_router(self, monkeypatch): monkeypatch.setenv("ENABLE_SMART_ROUTING_V2", "1") routed = { + "systemMessage": "Smart Router notice", "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow", "updatedInput": {"prompt": "fix it", "model": "opus"}, - } + "permissionDecisionReason": "Smart Router notice", + }, } with ( patch( "ucode.cli.smart_routing_v2.route_claude_subagent", - return_value={"updatedInput": routed["hookSpecificOutput"]["updatedInput"]}, + return_value=routed, ) as mock_v2_route, ): result = runner.invoke( @@ -1177,7 +1179,9 @@ def test_claude_v2_subagent_hook_uses_v2_router(self, monkeypatch): assert result.exit_code == 0, result.output assert json.loads(result.output) == routed + # A hook persisted by an earlier UG version gets its PreToolUse response and notice. mock_v2_route.assert_called_once() + assert mock_v2_route.call_args.kwargs["hook_output"] is True @pytest.mark.parametrize("enabled", [False, True]) def test_claude_mod_route_respects_session_controls(self, tmp_path, monkeypatch, enabled): @@ -1223,6 +1227,7 @@ def test_claude_mod_route_respects_session_controls(self, tmp_path, monkeypatch, token="token", available_models=["system.ai.claude-opus-4-8"], audit_decision=True, + hook_output=False, ) else: assert result.output == "" From f20334114eb311934cb80f82668d84b6b2e34b4e Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Fri, 16 Oct 2026 16:42:45 +0000 Subject: [PATCH 11/18] Mark Smart Router subagent rows and drop UG's routed agent names Prefix each routed Agent row's routing line with a lightning bolt so it reads as Smart Router's, omit ug-smart-router agent names that only repeat the model, and keep [orchestrator on] as its own suffix after the model. Co-authored-by: Isaac --- src/ucode/agents/claude_mods/constants.ts | 2 ++ src/ucode/agents/claude_mods/subagent-routing.ts | 10 ++++++++-- tests/native_claude/mod_tests/subagent-routing.test.ts | 10 +++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/ucode/agents/claude_mods/constants.ts b/src/ucode/agents/claude_mods/constants.ts index 60be6222e..095de739d 100644 --- a/src/ucode/agents/claude_mods/constants.ts +++ b/src/ucode/agents/claude_mods/constants.ts @@ -6,3 +6,5 @@ export const ENABLE_ROUTING_FLAG = '--enable-smart-routing'; export const DISABLE_ROUTING_FLAG = '--disable-smart-routing'; export const SMART_ROUTING_KEY = 'ENABLE_SMART_ROUTING_V2'; export const SUBAGENT_ONLY_KEY = 'ENABLE_SMART_ROUTING_SUBAGENT_ONLY'; +// Routed agent definitions UG generates; their names only repeat the model. +export const ROUTED_AGENT_PREFIX = 'ug-smart-router:'; diff --git a/src/ucode/agents/claude_mods/subagent-routing.ts b/src/ucode/agents/claude_mods/subagent-routing.ts index 762279428..3ccdf396e 100644 --- a/src/ucode/agents/claude_mods/subagent-routing.ts +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -1,6 +1,7 @@ import type { On, PluginOptions } from 'claude-code' +import { ROUTED_AGENT_PREFIX } from './constants.ts' -type RoutingRecord = { model: string; rationale: string; name: string } +type RoutingRecord = { model: string; rationale: string; name: string; orchestrator: boolean } const shortModel = (model: string): string => model.replace(/^system\.ai\./, '').replace(/^databricks-/, '') @@ -34,11 +35,13 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => if (!route) return next(e) if (tool_use_id) { // Name the agent that is dispatched, not the type the parent originally requested. + // UG's own routed definitions are omitted: the model already says what they are. const name = route.updatedInput.subagent_type records.set(tool_use_id, { model: route.model, rationale: route.rationale, - name: route.orchestrator === true ? `${name} [orchestrator on]` : name, + name: name.startsWith(ROUTED_AGENT_PREFIX) ? '' : name, + orchestrator: route.orchestrator === true, }) $.ui.invalidate('ui.render') } @@ -56,8 +59,11 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => flexDirection: 'row', children: [ Text({ dimColor: true, children: [' ↳ '] }), + // The lightning bolt marks the line as Smart Router's, not Claude's own output. + Text({ children: ['⚡ '] }), ...(record.name ? [Text({ color: 'primary', bold: true, children: [`${record.name} `] })] : []), Text({ color: 'primary', children: [shortModel(record.model)] }), + ...(record.orchestrator ? [Text({ dimColor: true, children: [' [orchestrator on]'] })] : []), ], }) const children = record.rationale diff --git a/tests/native_claude/mod_tests/subagent-routing.test.ts b/tests/native_claude/mod_tests/subagent-routing.test.ts index 83d961a60..3b2a6c4b7 100644 --- a/tests/native_claude/mod_tests/subagent-routing.test.ts +++ b/tests/native_claude/mod_tests/subagent-routing.test.ts @@ -41,16 +41,19 @@ test('routing preserves distinct results for identical tasks and forwards each c const a = await $.ui.mount(row('call-a')); expect(await a.find({ type: 'Text', text: 'original Agent row' })).toBeDefined(); expect(await a.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); + expect(await a.find({ type: 'Text', text: '⚡ ' })).toBeDefined(); expect(await a.find({ type: 'Text', text: ' Reason: Selected for call-a' })).toBeDefined(); expect(await a.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeUndefined(); - // The row names the dispatched agent, never the type the parent requested. - expect(await a.find({ type: 'Text', text: /^ug-smart-router:call-a\s*$/ })).toBeDefined(); + // UG's routed agent name only repeats the model, and the requested type is never shown. + expect(await a.find({ type: 'Text', text: /ug-smart-router:/ })).toBeUndefined(); expect(await a.find({ type: 'Text', text: /Explore/ })).toBeUndefined(); + expect(await a.find({ type: 'Text', text: /orchestrator/ })).toBeUndefined(); await a.unmount(); const b = await $.ui.mount(row('call-b')); expect(await b.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeDefined(); expect(await b.find({ type: 'Text', text: ' Reason: Selected for call-b' })).toBeDefined(); - expect(await b.find({ type: 'Text', text: /^ug-smart-router:call-b \[orchestrator on\]\s*$/ })).toBeDefined(); + expect(await b.find({ type: 'Text', text: /ug-smart-router:/ })).toBeUndefined(); + expect(await b.find({ type: 'Text', text: ' [orchestrator on]' })).toBeDefined(); await b.unmount(); }); @@ -79,6 +82,7 @@ for (const [name, result, warningCount] of [ const ui = await $.ui.mount(row('call-a')); expect(await ui.find({ type: 'Text', text: 'original Agent row' })).toBeDefined(); expect(await ui.find({ type: 'Text', text: /Reason:/ })).toBeUndefined(); + expect(await ui.find({ type: 'Text', text: /⚡/ })).toBeUndefined(); await ui.unmount(); }); } From e0124a11ffff2c5c038d652f36ef8514f35fa8da Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Fri, 16 Oct 2026 16:43:31 +0000 Subject: [PATCH 12/18] Remove the lightning bolt from Smart Router subagent rows Co-authored-by: Isaac --- src/ucode/agents/claude_mods/subagent-routing.ts | 2 -- tests/native_claude/mod_tests/subagent-routing.test.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/ucode/agents/claude_mods/subagent-routing.ts b/src/ucode/agents/claude_mods/subagent-routing.ts index 3ccdf396e..e28a5e533 100644 --- a/src/ucode/agents/claude_mods/subagent-routing.ts +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -59,8 +59,6 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => flexDirection: 'row', children: [ Text({ dimColor: true, children: [' ↳ '] }), - // The lightning bolt marks the line as Smart Router's, not Claude's own output. - Text({ children: ['⚡ '] }), ...(record.name ? [Text({ color: 'primary', bold: true, children: [`${record.name} `] })] : []), Text({ color: 'primary', children: [shortModel(record.model)] }), ...(record.orchestrator ? [Text({ dimColor: true, children: [' [orchestrator on]'] })] : []), diff --git a/tests/native_claude/mod_tests/subagent-routing.test.ts b/tests/native_claude/mod_tests/subagent-routing.test.ts index 3b2a6c4b7..5ba22b86f 100644 --- a/tests/native_claude/mod_tests/subagent-routing.test.ts +++ b/tests/native_claude/mod_tests/subagent-routing.test.ts @@ -41,7 +41,6 @@ test('routing preserves distinct results for identical tasks and forwards each c const a = await $.ui.mount(row('call-a')); expect(await a.find({ type: 'Text', text: 'original Agent row' })).toBeDefined(); expect(await a.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); - expect(await a.find({ type: 'Text', text: '⚡ ' })).toBeDefined(); expect(await a.find({ type: 'Text', text: ' Reason: Selected for call-a' })).toBeDefined(); expect(await a.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeUndefined(); // UG's routed agent name only repeats the model, and the requested type is never shown. @@ -82,7 +81,6 @@ for (const [name, result, warningCount] of [ const ui = await $.ui.mount(row('call-a')); expect(await ui.find({ type: 'Text', text: 'original Agent row' })).toBeDefined(); expect(await ui.find({ type: 'Text', text: /Reason:/ })).toBeUndefined(); - expect(await ui.find({ type: 'Text', text: /⚡/ })).toBeUndefined(); await ui.unmount(); }); } From 287d25a6ea1ca818fa935bd8179977d0cfb99541 Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Fri, 16 Oct 2026 17:45:35 +0000 Subject: [PATCH 13/18] Show the selected model, routing progress, and routing time on subagent rows Each routed Agent row now always shows the selected model in the status band's green, with the reason aligned beneath it. While the router answers, the row shows a spinner with the elapsed seconds, then notes how long routing took. Both timers use $.clock and are omitted when it is unavailable, so they never block routing. The orchestrator tag and the unused orchestrator field in the mod's routing reply are removed; the legacy hook notice keeps its tag. Co-authored-by: Isaac --- docs/claude-mod.md | 2 + src/ucode/agents/claude_mods/constants.ts | 2 - .../agents/claude_mods/subagent-routing.ts | 98 ++++++++++++++----- src/ucode/smart_routing/v2.py | 4 +- .../test_ug_smart_routing_hooks.py | 4 +- .../mod_tests/subagent-routing.test.ts | 49 +++++++++- tests/test_claude_smart_routing_v2.py | 2 - 7 files changed, 125 insertions(+), 36 deletions(-) diff --git a/docs/claude-mod.md b/docs/claude-mod.md index a63bf97a7..fd9638090 100644 --- a/docs/claude-mod.md +++ b/docs/claude-mod.md @@ -16,6 +16,8 @@ It runs `ug claude-router-hook route-subagent-mod` with a JSON payload on stdin. Python uses the same routing API client as Codex and returns the updated input, model, and rationale as JSON on stdout. The mod keeps that result in memory by `tool_use_id` and renders the model and rationale under the matching Agent row. +While the router answers, the row shows a spinner with the elapsed seconds; the selected +row then notes how long routing took. Both use `$.clock` and are omitted without it. It writes no routing sidecar file. Disabled routing or an unavailable route leaves the call unchanged; command failures and invalid responses also keep the original input and show a diagnostic. Other mods and Claude's permission checks still run. diff --git a/src/ucode/agents/claude_mods/constants.ts b/src/ucode/agents/claude_mods/constants.ts index 095de739d..60be6222e 100644 --- a/src/ucode/agents/claude_mods/constants.ts +++ b/src/ucode/agents/claude_mods/constants.ts @@ -6,5 +6,3 @@ export const ENABLE_ROUTING_FLAG = '--enable-smart-routing'; export const DISABLE_ROUTING_FLAG = '--disable-smart-routing'; export const SMART_ROUTING_KEY = 'ENABLE_SMART_ROUTING_V2'; export const SUBAGENT_ONLY_KEY = 'ENABLE_SMART_ROUTING_SUBAGENT_ONLY'; -// Routed agent definitions UG generates; their names only repeat the model. -export const ROUTED_AGENT_PREFIX = 'ug-smart-router:'; diff --git a/src/ucode/agents/claude_mods/subagent-routing.ts b/src/ucode/agents/claude_mods/subagent-routing.ts index e28a5e533..17677f332 100644 --- a/src/ucode/agents/claude_mods/subagent-routing.ts +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -1,18 +1,43 @@ import type { On, PluginOptions } from 'claude-code' -import { ROUTED_AGENT_PREFIX } from './constants.ts' -type RoutingRecord = { model: string; rationale: string; name: string; orchestrator: boolean } +type RoutingRecord = { model: string; rationale: string; seconds?: number } const shortModel = (model: string): string => model.replace(/^system\.ai\./, '').replace(/^databricks-/, '') +const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] +const SPINNER_INTERVAL_MS = 100 + +// The timer is cosmetic: without a clock it is omitted rather than blocking routing. +const now = async ($: any): Promise => { + try { + return await $.clock.now() + } catch { + return undefined + } +} export const registerSubagentRouting = (on: On, options: PluginOptions): void => { const records = new Map() + // Calls waiting on the router, by when routing started, shown with a spinner and timer. + const pending = new Map() + let frame = 0 on('tool.call', { tool: ['Agent', 'Task'] }, async ($, e, next) => { const command = options.routeCommand as readonly string[] if (!command.length) return next(e) const { tool, tool_use_id, agentId, ...input } = e let route + let spinner: { cancel: () => void } | undefined + if (tool_use_id) { + pending.set(tool_use_id, await now($)) + // Mods have no global timers; $.clock ticks the spinner while the router answers. + try { + spinner = $.clock.every(SPINNER_INTERVAL_MS, () => { + frame = (frame + 1) % SPINNER.length + $.ui.invalidate('ui.render') + }) + } catch {} + $.ui.invalidate('ui.render') + } try { const session_id = await $.session.id() const result = await $.process.run(command, { @@ -31,20 +56,25 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => } catch { route = undefined $.ui.log('Smart Router could not route this subagent; using its original settings.') + } finally { + spinner?.cancel() } - if (!route) return next(e) if (tool_use_id) { - // Name the agent that is dispatched, not the type the parent originally requested. - // UG's own routed definitions are omitted: the model already says what they are. - const name = route.updatedInput.subagent_type - records.set(tool_use_id, { - model: route.model, - rationale: route.rationale, - name: name.startsWith(ROUTED_AGENT_PREFIX) ? '' : name, - orchestrator: route.orchestrator === true, - }) + const startedAt = pending.get(tool_use_id) + pending.delete(tool_use_id) + const finishedAt = startedAt === undefined ? undefined : await now($) + if (route) { + records.set(tool_use_id, { + model: route.model, + rationale: route.rationale, + seconds: finishedAt === undefined || startedAt === undefined + ? undefined + : Math.max(0, finishedAt - startedAt) / 1000, + }) + } $.ui.invalidate('ui.render') } + if (!route) return next(e) // Preserve Claude's permission checks and every other mod's tool handlers. return next({ ...route.updatedInput, tool, tool_use_id, agentId }) }) @@ -52,21 +82,45 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => on('ui.render', { component: 'ToolUse' }, async ($, e, next) => { const original = await next(e) const record = records.get(e.requestId) - if (!record) return original + if (!record && !pending.has(e.requestId)) return original const { Box, Text } = $.ui.resolve(e) - const header = Box({ + if (!record) { + const startedAt = pending.get(e.requestId) + const current = startedAt === undefined ? undefined : await now($) + const elapsed = current === undefined || startedAt === undefined + ? '' + : ` ${Math.max(0, Math.floor((current - startedAt) / 1000))}s` + return Box({ flexDirection: 'column', children: [original, Box({ + flexDirection: 'row', + children: [ + Text({ dimColor: true, children: [' ↳ '] }), + Text({ color: 'green', children: [`${SPINNER[frame]} `] }), + Text({ dimColor: true, children: [`Selecting a model…${elapsed}`] }), + ], + })] }) + } + // Claude's row can name only the agent type, so the routed model is always shown. + const children = [original, Box({ flexDirection: 'row', children: [ - Text({ dimColor: true, children: [' ↳ '] }), - ...(record.name ? [Text({ color: 'primary', bold: true, children: [`${record.name} `] })] : []), - Text({ color: 'primary', children: [shortModel(record.model)] }), - ...(record.orchestrator ? [Text({ dimColor: true, children: [' [orchestrator on]'] })] : []), + // Labels recede so the routed model stands out. + Text({ dimColor: true, children: [' ↳ Selected Model: '] }), + // The same green as the status band's ON pill. + Text({ color: 'green', bold: true, children: [shortModel(record.model)] }), + // How long routing took; omitted when no clock was available. + ...(record.seconds === undefined + ? [] + : [Text({ dimColor: true, children: [` (routed in ${record.seconds.toFixed(1)}s)`] })]), ], - }) - const children = record.rationale - ? [original, header, Text({ dimColor: true, children: [` Reason: ${record.rationale}`] })] - : [original, header] + })] + // Align the reason, including its wrapped lines, under "Selected Model" after the arrow. + if (record.rationale) { + children.push(Box({ + paddingLeft: 4, + children: [Text({ children: [Text({ dimColor: true, children: ['Reason: '] }), record.rationale] })], + })) + } return Box({ flexDirection: 'column', children }) }) } diff --git a/src/ucode/smart_routing/v2.py b/src/ucode/smart_routing/v2.py index b1fe7ea7f..363ca0547 100644 --- a/src/ucode/smart_routing/v2.py +++ b/src/ucode/smart_routing/v2.py @@ -520,10 +520,9 @@ def route_claude_subagent( **{key: value for key, value in route.tool_input.items() if key != "model"}, "subagent_type": _routed_claude_agent_name(route.routed_model), } - orchestrator_enabled = orchestrator.enabled(agent=AGENT_CLAUDE) if hook_output: agent_name = updated_input["subagent_type"] - if orchestrator_enabled: + if orchestrator.enabled(agent=AGENT_CLAUDE): agent_name += " [orchestrator on]" routing_message = claude_routing.SUBAGENT_NOTICE_CONFIG.message( route.decision, @@ -543,7 +542,6 @@ def route_claude_subagent( "updatedInput": updated_input, "model": route.routed_model, "rationale": route.decision.rationale, - "orchestrator": orchestrator_enabled, } diff --git a/tests/integration/test_ug_smart_routing_hooks.py b/tests/integration/test_ug_smart_routing_hooks.py index 0b7ffbeec..12948c2ed 100644 --- a/tests/integration/test_ug_smart_routing_hooks.py +++ b/tests/integration/test_ug_smart_routing_hooks.py @@ -70,6 +70,7 @@ def _routing_display_for_task( lines = screen.splitlines() for index, line in enumerate(lines): if agent == "claude": + # The mod's routing line follows the tagged Agent row. if "↳" in line and marker in "".join( "\n".join(lines[max(0, index - 2) : index]).split() ): @@ -136,7 +137,7 @@ def _run_calculation( rationale = decision["rationale"] tui.wait_for( lambda screen: ( - model in "".join(screen.split()) + f"SelectedModel:{model}" in "".join(screen.split()) and ( not rationale or "".join(f"Reason: {rationale}".split()) in "".join(screen.split()) @@ -294,7 +295,6 @@ def test_smart_routing_claude_route_subagent_hook( assert updated["prompt"] == payload["tool_input"]["prompt"], updated assert updated["description"] == payload["tool_input"]["description"], updated assert "general-purpose" not in json.dumps(output), output - assert isinstance(output["orchestrator"], bool), output decisions_path = session.home / ".ucode" / "claude-smart-routing-decisions.jsonl" assert decisions_path.is_file(), f"hook wrote no routing decision record: {decisions_path}" diff --git a/tests/native_claude/mod_tests/subagent-routing.test.ts b/tests/native_claude/mod_tests/subagent-routing.test.ts index 5ba22b86f..0d0ef043b 100644 --- a/tests/native_claude/mod_tests/subagent-routing.test.ts +++ b/tests/native_claude/mod_tests/subagent-routing.test.ts @@ -23,7 +23,7 @@ test('routing preserves distinct results for identical tasks and forwards each c expect(payload.tool_input).toEqual(originalInput); const model = payload.tool_use_id === 'call-a' ? 'system.ai.claude-sonnet-5' : 'system.ai.claude-opus-4-8'; return { value: { exitCode: 0, stderr: '', stdout: JSON.stringify({ - model, rationale: `Selected for ${payload.tool_use_id}`, orchestrator: payload.tool_use_id === 'call-b', + model, rationale: `Selected for ${payload.tool_use_id}`, updatedInput: { prompt: originalInput.prompt, description: originalInput.description, subagent_type: `ug-smart-router:${payload.tool_use_id}` }, }) } }; }); @@ -40,19 +40,19 @@ test('routing preserves distinct results for identical tasks and forwards each c const a = await $.ui.mount(row('call-a')); expect(await a.find({ type: 'Text', text: 'original Agent row' })).toBeDefined(); + expect(await a.find({ type: 'Text', text: ' ↳ Selected Model: ' })).toBeDefined(); expect(await a.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); - expect(await a.find({ type: 'Text', text: ' Reason: Selected for call-a' })).toBeDefined(); + expect(await a.find({ type: 'Text', text: 'Reason: ' })).toBeDefined(); + expect(await a.find({ type: 'Text', text: /Selected for call-a/ })).toBeDefined(); expect(await a.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeUndefined(); // UG's routed agent name only repeats the model, and the requested type is never shown. expect(await a.find({ type: 'Text', text: /ug-smart-router:/ })).toBeUndefined(); expect(await a.find({ type: 'Text', text: /Explore/ })).toBeUndefined(); - expect(await a.find({ type: 'Text', text: /orchestrator/ })).toBeUndefined(); await a.unmount(); const b = await $.ui.mount(row('call-b')); expect(await b.find({ type: 'Text', text: 'claude-opus-4-8' })).toBeDefined(); - expect(await b.find({ type: 'Text', text: ' Reason: Selected for call-b' })).toBeDefined(); + expect(await b.find({ type: 'Text', text: /Selected for call-b/ })).toBeDefined(); expect(await b.find({ type: 'Text', text: /ug-smart-router:/ })).toBeUndefined(); - expect(await b.find({ type: 'Text', text: ' [orchestrator on]' })).toBeDefined(); await b.unmount(); }); @@ -81,6 +81,45 @@ for (const [name, result, warningCount] of [ const ui = await $.ui.mount(row('call-a')); expect(await ui.find({ type: 'Text', text: 'original Agent row' })).toBeDefined(); expect(await ui.find({ type: 'Text', text: /Reason:/ })).toBeUndefined(); + expect(await ui.find({ type: 'Text', text: /Selected Model/ })).toBeUndefined(); await ui.unmount(); }); } + +test('a call waiting on the router shows a spinner until its model is selected', async ($, on) => { + let release: (value: unknown) => void = () => {}; + const routed = new Promise((resolve) => { release = resolve; }); + let now = 10_000; + on('clock.now', () => ({ value: now })); + on('session.id', () => ({ value: 'session-1' })); + on('process.run', async () => { + await routed; + return { value: { exitCode: 0, stderr: '', stdout: JSON.stringify({ + model: 'system.ai.claude-sonnet-5', rationale: 'Selected for call-a', + updatedInput: { prompt: originalInput.prompt, subagent_type: 'ug-smart-router:call-a' }, + }) } }; + }); + on('tool.call', () => ({ result: 'ok' })); + on('ui.render', () => ({ type: 'Text', props: {}, children: ['original Agent row'] })); + + const call = $.tool.call({ tool: 'Agent', tool_use_id: 'call-a', ...originalInput }); + const waiting = await $.ui.mount(row('call-a')); + expect(await waiting.find({ type: 'Text', text: 'Selecting a model… 0s' })).toBeDefined(); + expect(await waiting.find({ type: 'Text', text: /Selected Model/ })).toBeUndefined(); + await waiting.unmount(); + // The timer counts whole seconds since routing started. + now += 2_500; + const later = await $.ui.mount(row('call-a')); + expect(await later.find({ type: 'Text', text: 'Selecting a model… 2s' })).toBeDefined(); + await later.unmount(); + + now += 700; + release(undefined); + await call; + const done = await $.ui.mount(row('call-a')); + expect(await done.find({ type: 'Text', text: /Selecting a model/ })).toBeUndefined(); + expect(await done.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); + // The selected row keeps how long routing took. + expect(await done.find({ type: 'Text', text: ' (routed in 3.2s)' })).toBeDefined(); + await done.unmount(); +}); diff --git a/tests/test_claude_smart_routing_v2.py b/tests/test_claude_smart_routing_v2.py index 160dccc71..157d86e66 100644 --- a/tests/test_claude_smart_routing_v2.py +++ b/tests/test_claude_smart_routing_v2.py @@ -762,7 +762,6 @@ def test_returns_routing_details_to_mod_without_writing_files(self, tmp_path, mo "prompt": "inspect the parser", "subagent_type": v2._routed_claude_agent_name("system.ai.claude-opus-4-8"), }, - "orchestrator": False, } assert not any(path.is_file() for path in tmp_path.rglob("*")) @@ -827,7 +826,6 @@ def route(**kwargs): assert output["updatedInput"]["subagent_type"] == routed_agent assert output["model"] == selected_model assert requested_agent not in json.dumps(output) - assert output["orchestrator"] is (orchestrator_enabled == "1") # Hooks persisted by earlier UG versions keep their PreToolUse response and notice. legacy = route(hook_output=True) From 484379a8192a38b7ee94fc0066c32243ec90ba81 Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Fri, 16 Oct 2026 19:59:25 +0000 Subject: [PATCH 14/18] Attribute Claude subagent routing rows by prompt order in live journeys The parent model writes the Agent row's description, so the task marker may appear only in the prompt. Match a Selected Model row after the tagged prompt and before the next one instead of requiring the marker above the row. Co-authored-by: Isaac --- .../test_ug_smart_routing_hooks.py | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/tests/integration/test_ug_smart_routing_hooks.py b/tests/integration/test_ug_smart_routing_hooks.py index 12948c2ed..b20a928bc 100644 --- a/tests/integration/test_ug_smart_routing_hooks.py +++ b/tests/integration/test_ug_smart_routing_hooks.py @@ -63,19 +63,35 @@ def _routing_decisions(session, agent: str) -> list[dict]: return read_jsonl(session.home / ".ucode" / f"{agent}-smart-routing-decisions.jsonl") +def _claude_routing_row_for_task(lines: list[str], marker: str) -> bool: + """Whether the mod's routing row follows the prompt that carries this task's marker. + + The parent chooses the Agent row's description, so the marker may appear only in the + prompt. Each calculation is its own prompt, so the row is attributed by screen order: + a selected-model line after the tagged prompt and before the next one. + """ + prompts = [index for index, line in enumerate(lines) if line.lstrip().startswith("❯")] + for position, start in enumerate(prompts): + end = start + 1 + while end < len(lines) and lines[end].strip(): + end += 1 + # The prompt wraps across indented lines; compare without rendered whitespace. + if marker not in "".join("".join(lines[start:end]).split()): + continue + following = prompts[position + 1] if position + 1 < len(prompts) else len(lines) + if any("↳ Selected Model:" in line for line in lines[end:following]): + return True + return False + + def _routing_display_for_task( screen: str, marker: str, *, agent: str, orchestrator_on: bool ) -> bool: """Whether the rendered router panel belongs to this uniquely tagged task.""" lines = screen.splitlines() + if agent == "claude": + return _claude_routing_row_for_task(lines, marker) for index, line in enumerate(lines): - if agent == "claude": - # The mod's routing line follows the tagged Agent row. - if "↳" in line and marker in "".join( - "\n".join(lines[max(0, index - 2) : index]).split() - ): - return True - continue if SMART_ROUTING_SUBAGENT_NOTICE not in line: continue panel = [] From 91dd0a895b9605bbd5a8cedbcfbcc6d558f73d9d Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Fri, 16 Oct 2026 20:08:50 +0000 Subject: [PATCH 15/18] Trim unrelated changes, redundant tests, and duplicate docs from subagent routing Drop the leftover truthy routing-flag parsing (flags take only 0 and 1), the Codex comment churn, a test that repeated the plugin file check, four redundant assertions, and doc paragraphs that restated each other. Share one helper for the routing timer's elapsed seconds. Co-authored-by: Isaac --- docs/claude-mod.md | 32 +++++-------- .../agents/claude_mods/subagent-routing.ts | 24 ++++------ src/ucode/cli.py | 2 +- src/ucode/constants.py | 2 - src/ucode/smart_routing/v2.py | 12 ++--- tests/README.md | 8 +--- tests/integration/README.md | 5 --- tests/test_claude_smart_routing_v2.py | 45 ------------------- 8 files changed, 27 insertions(+), 103 deletions(-) diff --git a/docs/claude-mod.md b/docs/claude-mod.md index fd9638090..0beeb054a 100644 --- a/docs/claude-mod.md +++ b/docs/claude-mod.md @@ -11,23 +11,17 @@ input with the permission mode) always ends with `smart router ON` or `smart rou routing launches use this path, and the existing temporary-directory lifecycle cleans up the files when Claude exits. -The same entry point intercepts Agent/Task calls through native `tool.call` hooks. -It runs `ug claude-router-hook route-subagent-mod` with a JSON payload on stdin. -Python uses the same routing API client as Codex and returns the updated input, -model, and rationale as JSON on stdout. The mod keeps that result in memory by -`tool_use_id` and renders the model and rationale under the matching Agent row. -While the router answers, the row shows a spinner with the elapsed seconds; the selected -row then notes how long routing took. Both use `$.clock` and are omitted without it. -It writes no routing sidecar file. Disabled routing or an unavailable route leaves -the call unchanged; command failures and invalid responses also keep the original -input and show a diagnostic. Other mods and Claude's permission checks still run. +The same entry point routes Agent/Task calls in a native `tool.call` hook. It pipes the +call as JSON to `ug claude-router-hook route-subagent-mod`, whose argument list arrives in +the `routeCommand` plugin option, and continues the call with the returned input. The +matching Agent row shows a spinner and elapsed seconds while the router answers, then the +selected model, routing time, and rationale. Results live in memory by `tool_use_id`, so a +plugin reload drops them. Disabled routing, command failures, and invalid responses keep +the original call; failures also log a diagnostic. -Launch settings pass the command's argument list through the native `routeCommand` -plugin option, including the workspace, profile/PAT selection, and offered model IDs. -SessionStart/SubagentStart audit hooks remain; the old routing PreToolUse hook is -removed so the same call cannot be routed twice. A `route-subagent` hook that an earlier -UG version persisted still gets its PreToolUse response and plain-text notice. UI details last for the loaded -mod's lifetime; a plugin reload does not restore earlier routing annotations. +The old routing PreToolUse hook is no longer written, so a call is not routed twice. A +`route-subagent` hook that an earlier UG version persisted still gets its PreToolUse +response and plain-text notice. `constants.ts` holds the values the mod shares with UG's CLI and the gateway: the recipe body field, the disabled recipe, the CLI module, agent, and routing flags, and the @@ -59,10 +53,8 @@ routing hooks apply over the environment, so an "off" saved earlier by the CLI o cannot outlast the command. The mod writes no file itself: it runs the existing `--enable-smart-routing`/`--disable-smart-routing` entry point, which validates and replaces that file atomically. Session start restores only the launch baseline, so an override saved -before `/clear` still survives it. The live skill-toggle journey covers the real CLI write; native component tests -use the test host's process stubs to check the subagent routing protocol and display. -A native process check also runs the real disabled Python CLI and verifies inherited -launch flags and a dummy token; it makes no gateway request. +before `/clear` still survives it. The live skill-toggle journey covers the real CLI write; +native component tests stub the process calls. The existing Claude skill-toggle integration journey checks recorded inference payloads during its three calculation phases: launch recipe, `DISABLED` after off, diff --git a/src/ucode/agents/claude_mods/subagent-routing.ts b/src/ucode/agents/claude_mods/subagent-routing.ts index 17677f332..5f9664896 100644 --- a/src/ucode/agents/claude_mods/subagent-routing.ts +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -14,6 +14,10 @@ const now = async ($: any): Promise => { return undefined } } +const secondsSince = async ($: any, start: number | undefined): Promise => { + const current = start === undefined ? undefined : await now($) + return current === undefined || start === undefined ? undefined : Math.max(0, current - start) / 1000 +} export const registerSubagentRouting = (on: On, options: PluginOptions): void => { const records = new Map() @@ -60,18 +64,9 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => spinner?.cancel() } if (tool_use_id) { - const startedAt = pending.get(tool_use_id) + const seconds = await secondsSince($, pending.get(tool_use_id)) pending.delete(tool_use_id) - const finishedAt = startedAt === undefined ? undefined : await now($) - if (route) { - records.set(tool_use_id, { - model: route.model, - rationale: route.rationale, - seconds: finishedAt === undefined || startedAt === undefined - ? undefined - : Math.max(0, finishedAt - startedAt) / 1000, - }) - } + if (route) records.set(tool_use_id, { model: route.model, rationale: route.rationale, seconds }) $.ui.invalidate('ui.render') } if (!route) return next(e) @@ -86,11 +81,8 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => const { Box, Text } = $.ui.resolve(e) if (!record) { - const startedAt = pending.get(e.requestId) - const current = startedAt === undefined ? undefined : await now($) - const elapsed = current === undefined || startedAt === undefined - ? '' - : ` ${Math.max(0, Math.floor((current - startedAt) / 1000))}s` + const seconds = await secondsSince($, pending.get(e.requestId)) + const elapsed = seconds === undefined ? '' : ` ${Math.floor(seconds)}s` return Box({ flexDirection: 'column', children: [original, Box({ flexDirection: 'row', children: [ diff --git a/src/ucode/cli.py b/src/ucode/cli.py index a52678eae..2c7b4727d 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -2524,7 +2524,7 @@ def codex_router_hook_cmd( ) ) # When matched is None the harness didn't report the subagent model — - # routing already injected the selected model, so emit nothing. + # the PreToolUse hook already injected the routed model, so emit nothing. return if event != "route-subagent" or not host: return diff --git a/src/ucode/constants.py b/src/ucode/constants.py index b6fb749e3..3ef84ee25 100644 --- a/src/ucode/constants.py +++ b/src/ucode/constants.py @@ -3,8 +3,6 @@ LOCALHOST = "localhost" LOOPBACK_HOST = "127.0.0.1" -# Values an on/off env var may use to mean "on" (compare after .strip().lower()). -TRUTHY_ENV_VALUES = frozenset({"1", "true"}) AGENT_CLAUDE = "claude" AGENT_CODEX = "codex" diff --git a/src/ucode/smart_routing/v2.py b/src/ucode/smart_routing/v2.py index 363ca0547..06103f979 100644 --- a/src/ucode/smart_routing/v2.py +++ b/src/ucode/smart_routing/v2.py @@ -35,7 +35,6 @@ LOOPBACK_HOST, SMART_ROUTER_CONFIG_VERSION_ENV_VAR, SMART_ROUTING_ENV_KEYS, - TRUTHY_ENV_VALUES, ) from ucode.custom_oauth import custom_oauth_cli_enabled, get_custom_client_token from ucode.databricks import ( @@ -170,10 +169,6 @@ def _model_picker_catalog() -> AnthropicModelCatalog | None: return None -def _env_truthy(value: str | None) -> bool: - return isinstance(value, str) and value.strip().lower() in TRUTHY_ENV_VALUES - - def smart_routing_enabled( env: MutableMapping[str, str] | None, *, default: bool, agent: str ) -> bool: @@ -181,7 +176,7 @@ def smart_routing_enabled( values = [ source.get(var) for var in (ENABLE_SMART_ROUTING_ENV_VAR, ENABLE_SUBAGENT_ROUTING_ENV_VAR) ] - if any(_env_truthy(value) for value in values): + if "1" in values: return True if "0" in values: return False @@ -193,8 +188,9 @@ def first_prompt_routing_enabled( ) -> bool: """Whether the first prompt is routed. Subagent-only wins over the full V2 flag.""" source = resolve_environment(env, agent=agent) - return _env_truthy(source.get(ENABLE_SMART_ROUTING_ENV_VAR)) and not _env_truthy( - source.get(ENABLE_SUBAGENT_ROUTING_ENV_VAR) + return ( + source.get(ENABLE_SMART_ROUTING_ENV_VAR) == "1" + and source.get(ENABLE_SUBAGENT_ROUTING_ENV_VAR) != "1" ) diff --git a/tests/README.md b/tests/README.md index 2f3a8543f..106f98194 100644 --- a/tests/README.md +++ b/tests/README.md @@ -575,9 +575,5 @@ 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 component checks cover Python's file-free JSON response, legacy hook replacement, -launch arguments, per-call rendering, and disabled/failed routing. A native check runs the -real disabled CLI with inherited flags and a dummy token. The live Claude skill-toggle -journey exercises the routing command against the gateway; component passes do not prove -live coverage. See [the mod guide](../docs/claude-mod.md). +A native process check runs the real disabled routing CLI from Claude with inherited +launch flags; it makes no gateway request. diff --git a/tests/integration/README.md b/tests/integration/README.md index 57959c665..c2fdf3086 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1082,8 +1082,3 @@ 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. - -The Claude skill-toggle journey exercises the mod's Python routing command against the -live gateway. Component protocol, rendering, fallback, and subprocess coverage is documented -in [the test overview](../README.md) and [mod guide](../../docs/claude-mod.md); those checks -do not establish live gateway/TUI coverage. diff --git a/tests/test_claude_smart_routing_v2.py b/tests/test_claude_smart_routing_v2.py index 157d86e66..fbc508b87 100644 --- a/tests/test_claude_smart_routing_v2.py +++ b/tests/test_claude_smart_routing_v2.py @@ -217,17 +217,6 @@ def test_subagent_only_flag_suppresses_first_prompt_routing(self, monkeypatch): monkeypatch.delenv(v2.ENABLE_SUBAGENT_ROUTING_ENV_VAR) assert v2.first_prompt_routing_enabled(agent=AGENT_CLAUDE) - def test_smart_routing_vars_accept_true_as_well_as_one(self): - v2_var = v2.ENABLE_SMART_ROUTING_ENV_VAR - sub_var = v2.ENABLE_SUBAGENT_ROUTING_ENV_VAR - assert v2.smart_routing_enabled({v2_var: "true"}, default=False, agent=AGENT_CLAUDE) - assert v2.smart_routing_enabled({sub_var: "TRUE"}, default=False, agent=AGENT_CLAUDE) - assert not v2.smart_routing_enabled({v2_var: "yes"}, default=False, agent=AGENT_CLAUDE) - assert v2.first_prompt_routing_enabled({v2_var: "true"}, agent=AGENT_CLAUDE) - assert not v2.first_prompt_routing_enabled( - {v2_var: "true", sub_var: "True"}, agent=AGENT_CLAUDE - ) - class TestV2Launch: def test_strips_gateway_prefix_for_interposer(self): @@ -725,8 +714,6 @@ def fake_select(workspace, token, task, route_options, resolve, **kwargs): assert updated_input["subagent_type"] == v2._routed_claude_agent_name( "system.ai.claude-opus-4-8" ) - assert "systemMessage" not in output - assert "permissionDecisionReason" not in output decision_record = json.loads(decisions_path.read_text()) assert decision_record["requested_model"] == "system.ai.claude-opus-4-8" @@ -824,7 +811,6 @@ def route(**kwargs): # The mod labels the row from updatedInput, so it names the dispatched agent. output = route() assert output["updatedInput"]["subagent_type"] == routed_agent - assert output["model"] == selected_model assert requested_agent not in json.dumps(output) # Hooks persisted by earlier UG versions keep their PreToolUse response and notice. @@ -843,7 +829,6 @@ def test_writes_routed_agents_as_launch_scoped_plugin(self, tmp_path): v2._write_routed_claude_plugin(plugin_dir, models) - assert (plugin_dir / "hooks/register.ts").is_file() manifest = json.loads((plugin_dir / ".claude-plugin" / "plugin.json").read_text()) assert manifest["name"] == "ug-smart-router" assert _plugin_agent_models(plugin_dir) == { @@ -857,36 +842,6 @@ def test_writes_routed_agents_as_launch_scoped_plugin(self, tmp_path): assert v2.CLAUDE_ROUTED_AGENT_PROMPT in agent assert v2._routed_claude_agent_name(model) == f"{manifest['name']}:{slug}" - def test_includes_subagent_routing_mod(self, tmp_path): - plugin_dir = tmp_path / "routing-plugin" - - v2._write_routed_claude_plugin(plugin_dir, ["system.ai.claude-opus-4-8"]) - - hooks_dir = plugin_dir / "hooks" - hooks = json.loads((hooks_dir / "hooks.json").read_text()) - # One hooks module (register.ts) per plugin; the files it imports ride along. - assert hooks == {"modules": ["./register.ts"]} - for name in ( - "register.ts", - "constants.ts", - "smart-routing-status.ts", - "subagent-routing.ts", - ): - assert (hooks_dir / name).is_file() - entry = (hooks_dir / "register.ts").read_text() - assert "registerStatusBand" in entry and "registerSubagentRouting" in entry - - status = (hooks_dir / "smart-routing-status.ts").read_text() - assert "unity gateway smart router" in status - assert "ui.render" in status and "AbovePrompt" in status - # Env var names stay in sync with the Python source of truth. - assert v2.ENABLE_SMART_ROUTING_ENV_VAR in status - assert v2.ENABLE_SUBAGENT_ROUTING_ENV_VAR in status - assert v2.SESSION_ENV_VAR in status - - subagents = (hooks_dir / "subagent-routing.ts").read_text() - assert "ui.render" in subagents and "ToolUse" in subagents - def test_leaves_non_claude_custom_agent_model_unchanged(self): definitions = v2._routed_claude_agent_definitions(["catalog.schema.gpt-5"]) From 82f11cda6e71f75d0c8d2da22bdef9ed6abadbb3 Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Fri, 16 Oct 2026 21:02:55 +0000 Subject: [PATCH 16/18] Use Claude's own spinner glyphs while a subagent is routed Co-authored-by: Isaac --- src/ucode/agents/claude_mods/subagent-routing.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ucode/agents/claude_mods/subagent-routing.ts b/src/ucode/agents/claude_mods/subagent-routing.ts index 5f9664896..c6f4496c6 100644 --- a/src/ucode/agents/claude_mods/subagent-routing.ts +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -3,7 +3,9 @@ import type { On, PluginOptions } from 'claude-code' type RoutingRecord = { model: string; rationale: string; seconds?: number } const shortModel = (model: string): string => model.replace(/^system\.ai\./, '').replace(/^databricks-/, '') -const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] +// Claude's own spinner glyphs, played forwards then back as Claude does. +const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽'] +const SPINNER = [...SPINNER_GLYPHS, ...SPINNER_GLYPHS.slice(1, -1).reverse()] const SPINNER_INTERVAL_MS = 100 // The timer is cosmetic: without a clock it is omitted rather than blocking routing. From b3551b7bb83b2eb8ee4c45207d93ce3c0e3c72c8 Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Fri, 16 Oct 2026 21:05:48 +0000 Subject: [PATCH 17/18] Address review: steady spinner, split routing replies, drop dead argv branch Derive the routing spinner frame from the clock so parallel subagents no longer speed it up. Split route_claude_subagent's two reply shapes into the mod's function and a route_claude_pre_tool_use wrapper for persisted legacy hooks, sharing one routing helper. routing_hook_argv now adds router options only for the mod event, since nothing builds a Claude route-subagent argv. Co-authored-by: Isaac --- .../agents/claude_mods/subagent-routing.ts | 12 ++- src/ucode/cli.py | 12 ++- src/ucode/smart_routing/claude_hooks.py | 2 +- src/ucode/smart_routing/v2.py | 89 +++++++++++++------ .../mod_tests/subagent-routing.test.ts | 12 ++- tests/test_claude_smart_routing_v2.py | 9 +- tests/test_cli.py | 4 +- 7 files changed, 90 insertions(+), 50 deletions(-) diff --git a/src/ucode/agents/claude_mods/subagent-routing.ts b/src/ucode/agents/claude_mods/subagent-routing.ts index c6f4496c6..8442cf0b1 100644 --- a/src/ucode/agents/claude_mods/subagent-routing.ts +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -25,7 +25,6 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => const records = new Map() // Calls waiting on the router, by when routing started, shown with a spinner and timer. const pending = new Map() - let frame = 0 on('tool.call', { tool: ['Agent', 'Task'] }, async ($, e, next) => { const command = options.routeCommand as readonly string[] @@ -35,12 +34,10 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => let spinner: { cancel: () => void } | undefined if (tool_use_id) { pending.set(tool_use_id, await now($)) - // Mods have no global timers; $.clock ticks the spinner while the router answers. + // Mods have no global timers; $.clock redraws the spinner while the router answers. + // The frame comes from the clock, so parallel calls do not speed it up. try { - spinner = $.clock.every(SPINNER_INTERVAL_MS, () => { - frame = (frame + 1) % SPINNER.length - $.ui.invalidate('ui.render') - }) + spinner = $.clock.every(SPINNER_INTERVAL_MS, () => $.ui.invalidate('ui.render')) } catch {} $.ui.invalidate('ui.render') } @@ -83,13 +80,14 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void => const { Box, Text } = $.ui.resolve(e) if (!record) { + const glyph = SPINNER[Math.floor(((await now($)) ?? 0) / SPINNER_INTERVAL_MS) % SPINNER.length] const seconds = await secondsSince($, pending.get(e.requestId)) const elapsed = seconds === undefined ? '' : ` ${Math.floor(seconds)}s` return Box({ flexDirection: 'column', children: [original, Box({ flexDirection: 'row', children: [ Text({ dimColor: true, children: [' ↳ '] }), - Text({ color: 'green', children: [`${SPINNER[frame]} `] }), + Text({ color: 'green', children: [`${glyph} `] }), Text({ dimColor: true, children: [`Selecting a model…${elapsed}`] }), ], })] }) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 2c7b4727d..9df31ce11 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -2637,15 +2637,19 @@ def claude_router_hook_cmd( token = get_databricks_token(host, profile) except RuntimeError: return - output = smart_routing_v2.route_claude_subagent( + # Hooks persisted by earlier UG versions still call route-subagent and expect a + # PreToolUse response; the mod's route-subagent-mod event takes the plain details. + route = ( + smart_routing_v2.route_claude_pre_tool_use + if event == "route-subagent" + else smart_routing_v2.route_claude_subagent + ) + output = route( payload, workspace=host, token=token, available_models=model or [], audit_decision=True, - # Hooks persisted by earlier UG versions still call route-subagent and expect a - # PreToolUse response; the mod's route-subagent-mod event takes the plain details. - hook_output=event == "route-subagent", ) if output is not None: sys.stdout.write(json.dumps(output)) diff --git a/src/ucode/smart_routing/claude_hooks.py b/src/ucode/smart_routing/claude_hooks.py index 29c021a24..d08d3ea7b 100644 --- a/src/ucode/smart_routing/claude_hooks.py +++ b/src/ucode/smart_routing/claude_hooks.py @@ -75,7 +75,7 @@ def routing_hook_argv(state: dict, event: str) -> list[str]: ROUTING_HOOK_COMMAND_MARKER, event, ] - if event not in {"route-subagent", ROUTE_SUBAGENT_MOD_EVENT}: + if event != ROUTE_SUBAGENT_MOD_EVENT: return argv argv += ["--host", workspace] profile = state.get("profile") diff --git a/src/ucode/smart_routing/v2.py b/src/ucode/smart_routing/v2.py index 06103f979..8693d7d1d 100644 --- a/src/ucode/smart_routing/v2.py +++ b/src/ucode/smart_routing/v2.py @@ -479,20 +479,15 @@ def _route_print_mode_prompt( return model_name(_unwrapped_claude_model_id(decision.model)) -def route_claude_subagent( +def _route_claude_agent( payload: dict, *, workspace: str, token: str, available_models: list[str], - audit_decision: bool = False, - hook_output: bool = False, -) -> dict | None: - """Return a routed Agent input and UI details using the shared Python client. - - ``hook_output`` returns the legacy PreToolUse hook response instead, with its plain-text - notice, for hooks that earlier UG versions persisted in settings files. - """ + audit_decision: bool, +) -> tuple[routing.SpawnRoute, dict] | None: + """Route an Agent call to a transient exact-model agent definition, with its new input.""" route = routing.resolve_spawn_route( payload, is_spawn_agent=claude_routing.is_spawn_agent_tool, @@ -516,24 +511,28 @@ def route_claude_subagent( **{key: value for key, value in route.tool_input.items() if key != "model"}, "subagent_type": _routed_claude_agent_name(route.routed_model), } - if hook_output: - agent_name = updated_input["subagent_type"] - if orchestrator.enabled(agent=AGENT_CLAUDE): - agent_name += " [orchestrator on]" - routing_message = claude_routing.SUBAGENT_NOTICE_CONFIG.message( - route.decision, - route.routed_model, - {**updated_input, "subagent_type": agent_name}, - ) - return { - "systemMessage": routing_message, - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "updatedInput": updated_input, - "permissionDecisionReason": routing_message, - }, - } + return route, updated_input + + +def route_claude_subagent( + payload: dict, + *, + workspace: str, + token: str, + available_models: list[str], + audit_decision: bool = False, +) -> dict | None: + """Return the routed Agent input and the details the native mod renders.""" + routed = _route_claude_agent( + payload, + workspace=workspace, + token=token, + available_models=available_models, + audit_decision=audit_decision, + ) + if routed is None: + return None + route, updated_input = routed return { "updatedInput": updated_input, "model": route.routed_model, @@ -541,6 +540,42 @@ def route_claude_subagent( } +def route_claude_pre_tool_use( + payload: dict, + *, + workspace: str, + token: str, + available_models: list[str], + audit_decision: bool = False, +) -> dict | None: + """Answer a PreToolUse routing hook that an earlier UG version persisted in settings.""" + routed = _route_claude_agent( + payload, + workspace=workspace, + token=token, + available_models=available_models, + audit_decision=audit_decision, + ) + if routed is None: + return None + route, updated_input = routed + agent_name = updated_input["subagent_type"] + if orchestrator.enabled(agent=AGENT_CLAUDE): + agent_name += " [orchestrator on]" + routing_message = claude_routing.SUBAGENT_NOTICE_CONFIG.message( + route.decision, + route.routed_model, + {**updated_input, "subagent_type": agent_name}, + ) + hook_output = { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": updated_input, + "permissionDecisionReason": routing_message, + } + return {"systemMessage": routing_message, "hookSpecificOutput": hook_output} + + def _is_claude_target_model(value: object) -> bool: if not isinstance(value, str): return False diff --git a/tests/native_claude/mod_tests/subagent-routing.test.ts b/tests/native_claude/mod_tests/subagent-routing.test.ts index 0d0ef043b..6b8612af8 100644 --- a/tests/native_claude/mod_tests/subagent-routing.test.ts +++ b/tests/native_claude/mod_tests/subagent-routing.test.ts @@ -109,13 +109,19 @@ test('a call waiting on the router shows a spinner until its model is selected', await waiting.unmount(); // The timer counts whole seconds since routing started. now += 2_500; - const later = await $.ui.mount(row('call-a')); - expect(await later.find({ type: 'Text', text: 'Selecting a model… 2s' })).toBeDefined(); - await later.unmount(); + // A parallel call starts its own ticker, but both rows show the frame the clock gives. + const parallel = $.tool.call({ tool: 'Agent', tool_use_id: 'call-b', ...originalInput }); + for (const id of ['call-a', 'call-b']) { + const later = await $.ui.mount(row(id)); + expect(await later.find({ type: 'Text', text: '✽ ' })).toBeDefined(); + if (id === 'call-a') expect(await later.find({ type: 'Text', text: 'Selecting a model… 2s' })).toBeDefined(); + await later.unmount(); + } now += 700; release(undefined); await call; + await parallel; const done = await $.ui.mount(row('call-a')); expect(await done.find({ type: 'Text', text: /Selecting a model/ })).toBeUndefined(); expect(await done.find({ type: 'Text', text: 'claude-sonnet-5' })).toBeDefined(); diff --git a/tests/test_claude_smart_routing_v2.py b/tests/test_claude_smart_routing_v2.py index fbc508b87..1c4eeb9a0 100644 --- a/tests/test_claude_smart_routing_v2.py +++ b/tests/test_claude_smart_routing_v2.py @@ -799,22 +799,21 @@ def test_routed_agent_details_match_executed_agent( }, } - def route(**kwargs): - return v2.route_claude_subagent( + def route(route_fn): + return route_fn( payload, workspace="https://example.com", token="token", available_models=[selected_model, "system.ai.glm-5-3-flash"], - **kwargs, ) # The mod labels the row from updatedInput, so it names the dispatched agent. - output = route() + output = route(v2.route_claude_subagent) assert output["updatedInput"]["subagent_type"] == routed_agent assert requested_agent not in json.dumps(output) # Hooks persisted by earlier UG versions keep their PreToolUse response and notice. - legacy = route(hook_output=True) + legacy = route(v2.route_claude_pre_tool_use) hook = legacy["hookSpecificOutput"] assert hook["permissionDecision"] == "allow" assert hook["updatedInput"] == output["updatedInput"] diff --git a/tests/test_cli.py b/tests/test_cli.py index 0db439900..a4166f3ab 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1159,7 +1159,7 @@ def test_claude_v2_subagent_hook_uses_v2_router(self, monkeypatch): } with ( patch( - "ucode.cli.smart_routing_v2.route_claude_subagent", + "ucode.cli.smart_routing_v2.route_claude_pre_tool_use", return_value=routed, ) as mock_v2_route, ): @@ -1181,7 +1181,6 @@ def test_claude_v2_subagent_hook_uses_v2_router(self, monkeypatch): assert json.loads(result.output) == routed # A hook persisted by an earlier UG version gets its PreToolUse response and notice. mock_v2_route.assert_called_once() - assert mock_v2_route.call_args.kwargs["hook_output"] is True @pytest.mark.parametrize("enabled", [False, True]) def test_claude_mod_route_respects_session_controls(self, tmp_path, monkeypatch, enabled): @@ -1227,7 +1226,6 @@ def test_claude_mod_route_respects_session_controls(self, tmp_path, monkeypatch, token="token", available_models=["system.ai.claude-opus-4-8"], audit_decision=True, - hook_output=False, ) else: assert result.output == "" From 1f1eb1b1923c4d5dd68add8c2ed4f9d3c218c5b2 Mon Sep 17 00:00:00 2001 From: "lilly.luo" Date: Fri, 16 Oct 2026 21:07:00 +0000 Subject: [PATCH 18/18] Keep one Claude subagent routing function to keep the change small Co-authored-by: Isaac --- src/ucode/cli.py | 12 ++-- src/ucode/smart_routing/v2.py | 89 ++++++++------------------- tests/test_claude_smart_routing_v2.py | 9 +-- tests/test_cli.py | 4 +- 4 files changed, 39 insertions(+), 75 deletions(-) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 9df31ce11..2c7b4727d 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -2637,19 +2637,15 @@ def claude_router_hook_cmd( token = get_databricks_token(host, profile) except RuntimeError: return - # Hooks persisted by earlier UG versions still call route-subagent and expect a - # PreToolUse response; the mod's route-subagent-mod event takes the plain details. - route = ( - smart_routing_v2.route_claude_pre_tool_use - if event == "route-subagent" - else smart_routing_v2.route_claude_subagent - ) - output = route( + output = smart_routing_v2.route_claude_subagent( payload, workspace=host, token=token, available_models=model or [], audit_decision=True, + # Hooks persisted by earlier UG versions still call route-subagent and expect a + # PreToolUse response; the mod's route-subagent-mod event takes the plain details. + hook_output=event == "route-subagent", ) if output is not None: sys.stdout.write(json.dumps(output)) diff --git a/src/ucode/smart_routing/v2.py b/src/ucode/smart_routing/v2.py index 8693d7d1d..06103f979 100644 --- a/src/ucode/smart_routing/v2.py +++ b/src/ucode/smart_routing/v2.py @@ -479,15 +479,20 @@ def _route_print_mode_prompt( return model_name(_unwrapped_claude_model_id(decision.model)) -def _route_claude_agent( +def route_claude_subagent( payload: dict, *, workspace: str, token: str, available_models: list[str], - audit_decision: bool, -) -> tuple[routing.SpawnRoute, dict] | None: - """Route an Agent call to a transient exact-model agent definition, with its new input.""" + audit_decision: bool = False, + hook_output: bool = False, +) -> dict | None: + """Return a routed Agent input and UI details using the shared Python client. + + ``hook_output`` returns the legacy PreToolUse hook response instead, with its plain-text + notice, for hooks that earlier UG versions persisted in settings files. + """ route = routing.resolve_spawn_route( payload, is_spawn_agent=claude_routing.is_spawn_agent_tool, @@ -511,28 +516,24 @@ def _route_claude_agent( **{key: value for key, value in route.tool_input.items() if key != "model"}, "subagent_type": _routed_claude_agent_name(route.routed_model), } - return route, updated_input - - -def route_claude_subagent( - payload: dict, - *, - workspace: str, - token: str, - available_models: list[str], - audit_decision: bool = False, -) -> dict | None: - """Return the routed Agent input and the details the native mod renders.""" - routed = _route_claude_agent( - payload, - workspace=workspace, - token=token, - available_models=available_models, - audit_decision=audit_decision, - ) - if routed is None: - return None - route, updated_input = routed + if hook_output: + agent_name = updated_input["subagent_type"] + if orchestrator.enabled(agent=AGENT_CLAUDE): + agent_name += " [orchestrator on]" + routing_message = claude_routing.SUBAGENT_NOTICE_CONFIG.message( + route.decision, + route.routed_model, + {**updated_input, "subagent_type": agent_name}, + ) + return { + "systemMessage": routing_message, + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": updated_input, + "permissionDecisionReason": routing_message, + }, + } return { "updatedInput": updated_input, "model": route.routed_model, @@ -540,42 +541,6 @@ def route_claude_subagent( } -def route_claude_pre_tool_use( - payload: dict, - *, - workspace: str, - token: str, - available_models: list[str], - audit_decision: bool = False, -) -> dict | None: - """Answer a PreToolUse routing hook that an earlier UG version persisted in settings.""" - routed = _route_claude_agent( - payload, - workspace=workspace, - token=token, - available_models=available_models, - audit_decision=audit_decision, - ) - if routed is None: - return None - route, updated_input = routed - agent_name = updated_input["subagent_type"] - if orchestrator.enabled(agent=AGENT_CLAUDE): - agent_name += " [orchestrator on]" - routing_message = claude_routing.SUBAGENT_NOTICE_CONFIG.message( - route.decision, - route.routed_model, - {**updated_input, "subagent_type": agent_name}, - ) - hook_output = { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "updatedInput": updated_input, - "permissionDecisionReason": routing_message, - } - return {"systemMessage": routing_message, "hookSpecificOutput": hook_output} - - def _is_claude_target_model(value: object) -> bool: if not isinstance(value, str): return False diff --git a/tests/test_claude_smart_routing_v2.py b/tests/test_claude_smart_routing_v2.py index 1c4eeb9a0..fbc508b87 100644 --- a/tests/test_claude_smart_routing_v2.py +++ b/tests/test_claude_smart_routing_v2.py @@ -799,21 +799,22 @@ def test_routed_agent_details_match_executed_agent( }, } - def route(route_fn): - return route_fn( + def route(**kwargs): + return v2.route_claude_subagent( payload, workspace="https://example.com", token="token", available_models=[selected_model, "system.ai.glm-5-3-flash"], + **kwargs, ) # The mod labels the row from updatedInput, so it names the dispatched agent. - output = route(v2.route_claude_subagent) + output = route() assert output["updatedInput"]["subagent_type"] == routed_agent assert requested_agent not in json.dumps(output) # Hooks persisted by earlier UG versions keep their PreToolUse response and notice. - legacy = route(v2.route_claude_pre_tool_use) + legacy = route(hook_output=True) hook = legacy["hookSpecificOutput"] assert hook["permissionDecision"] == "allow" assert hook["updatedInput"] == output["updatedInput"] diff --git a/tests/test_cli.py b/tests/test_cli.py index a4166f3ab..0db439900 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1159,7 +1159,7 @@ def test_claude_v2_subagent_hook_uses_v2_router(self, monkeypatch): } with ( patch( - "ucode.cli.smart_routing_v2.route_claude_pre_tool_use", + "ucode.cli.smart_routing_v2.route_claude_subagent", return_value=routed, ) as mock_v2_route, ): @@ -1181,6 +1181,7 @@ def test_claude_v2_subagent_hook_uses_v2_router(self, monkeypatch): assert json.loads(result.output) == routed # A hook persisted by an earlier UG version gets its PreToolUse response and notice. mock_v2_route.assert_called_once() + assert mock_v2_route.call_args.kwargs["hook_output"] is True @pytest.mark.parametrize("enabled", [False, True]) def test_claude_mod_route_respects_session_controls(self, tmp_path, monkeypatch, enabled): @@ -1226,6 +1227,7 @@ def test_claude_mod_route_respects_session_controls(self, tmp_path, monkeypatch, token="token", available_models=["system.ai.claude-opus-4-8"], audit_decision=True, + hook_output=False, ) else: assert result.output == ""