diff --git a/docs/claude-mod.md b/docs/claude-mod.md index bc9fe01f7..0beeb054a 100644 --- a/docs/claude-mod.md +++ b/docs/claude-mod.md @@ -11,6 +11,18 @@ 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 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. + +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 routing keys used to read session overrides. It ships @@ -41,8 +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 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 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/register.ts b/src/ucode/agents/claude_mods/register.ts index 2963849fd..277b5be10 100644 --- a/src/ucode/agents/claude_mods/register.ts +++ b/src/ucode/agents/claude_mods/register.ts @@ -1,5 +1,6 @@ import type { EngineInterface, On, PluginOptions } from 'claude-code'; import { registerStatusBand, showStatusBand } from './smart-routing-status.ts'; +import { registerSubagentRouting } from './subagent-routing.ts'; import { CLI_AGENT, CLI_MODULE, @@ -109,4 +110,5 @@ export function register(on: On, options: PluginOptions): void { } }); registerStatusBand(on); + registerSubagentRouting(on, options); } diff --git a/src/ucode/agents/claude_mods/subagent-routing.ts b/src/ucode/agents/claude_mods/subagent-routing.ts new file mode 100644 index 000000000..8442cf0b1 --- /dev/null +++ b/src/ucode/agents/claude_mods/subagent-routing.ts @@ -0,0 +1,118 @@ +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-/, '') +// 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. +const now = async ($: any): Promise => { + try { + return await $.clock.now() + } catch { + 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() + // Calls waiting on the router, by when routing started, shown with a spinner and timer. + const pending = new Map() + + 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 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, () => $.ui.invalidate('ui.render')) + } catch {} + $.ui.invalidate('ui.render') + } + 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. + 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 + $.ui.log('Smart Router could not route this subagent; using its original settings.') + } finally { + spinner?.cancel() + } + if (tool_use_id) { + const seconds = await secondsSince($, pending.get(tool_use_id)) + pending.delete(tool_use_id) + if (route) records.set(tool_use_id, { model: route.model, rationale: route.rationale, seconds }) + $.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 }) + }) + + on('ui.render', { component: 'ToolUse' }, async ($, e, next) => { + const original = await next(e) + const record = records.get(e.requestId) + if (!record && !pending.has(e.requestId)) return original + + 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: [`${glyph} `] }), + 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: [ + // 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)`] })]), + ], + })] + // 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/cli.py b/src/ucode/cli.py index 48daa32aa..2c7b4727d 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, @@ -2621,9 +2625,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: @@ -2633,12 +2637,15 @@ def claude_router_hook_cmd( token = get_databricks_token(host, profile) except RuntimeError: return - output = smart_routing_v2.route_claude_pre_tool_use( + 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/claude_hooks.py b/src/ucode/smart_routing/claude_hooks.py index 837326c00..d08d3ea7b 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,12 +14,13 @@ 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) -> None: - """Synchronize ucode-managed routing hooks in a Claude settings document.""" + """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) @@ -49,16 +48,9 @@ def sync_first_prompt_hook(doc: dict, executable: str) -> None: def _routing_hook_groups(state: dict) -> 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") + 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="Routing subagent model")], - } - ], "SessionStart": [ { "matcher": "startup|resume|clear", @@ -73,7 +65,8 @@ def _routing_hook_groups(state: dict) -> dict[str, list[dict]]: } -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")))[ @@ -82,7 +75,7 @@ def _routing_hook_argv(state: dict, event: str) -> list[str]: ROUTING_HOOK_COMMAND_MARKER, event, ] - if event != "route-subagent": + 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 2aa43ab4c..06103f979 100644 --- a/src/ucode/smart_routing/v2.py +++ b/src/ucode/smart_routing/v2.py @@ -59,6 +59,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, ) @@ -342,6 +344,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": [], } @@ -355,6 +358,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 @@ -395,7 +405,13 @@ 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" # These are imported by the entry point, so they ship beside it rather than as modules. - for name in ("register.ts", "constants.ts", "extra-body.ts", "smart-routing-status.ts"): + for name in ( + "register.ts", + "constants.ts", + "extra-body.ts", + "smart-routing-status.ts", + "subagent-routing.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"]}) @@ -463,15 +479,20 @@ 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, token: str, available_models: list[str], audit_decision: bool = False, + hook_output: 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. + + ``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, @@ -495,21 +516,29 @@ 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), } - agent_name = updated_input.get("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", + 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, - "permissionDecisionReason": routing_message, + "model": route.routed_model, + "rationale": route.decision.rationale, } - return {"systemMessage": routing_message, "hookSpecificOutput": hook_output} def _is_claude_target_model(value: object) -> bool: @@ -689,7 +718,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 c1687e11b..106f98194 100644 --- a/tests/README.md +++ b/tests/README.md @@ -359,8 +359,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; Claude's notice names the routed agent; 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 payloads carry `smart_router_recipe_name` as launch recipe -> DISABLED -> launch recipe, omit the recipe header, 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 naming the routed agent the mod labels; 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 payloads carry `smart_router_recipe_name` as launch recipe -> DISABLED -> launch recipe, omit the recipe header, 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 | @@ -556,8 +556,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. The TypeScript launch test also checks event/result forwarding. Separate cases cover denied-write rollback, invalid request bodies, disabled launches, and body-merge validation; @@ -575,3 +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. +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 6e8c987f2..c2fdf3086 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -370,13 +370,14 @@ 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 -survival and native daemon/background dispatch remain deferred. CUJ4 covers interactive -explicit-model bypass. Plugin generation and launch arguments are covered +native model/rationale row (Claude) or subagent banner (Codex), plus a correlated +live decision only while routing is enabled. Plugin-refresh survival and native +daemon/background dispatch remain deferred. CUJ4 covers interactive explicit-model +bypass. Plugin generation and launch arguments are covered by component tests in `../test_claude_smart_routing_v2.py`, not by a live registration journey. PATH conflicts for the Smart Router skill have subprocess/component coverage in @@ -393,8 +394,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, @@ -1061,7 +1063,8 @@ before work and after off/on toggles. Checks ignore terminal styling and trailin 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 +is covered by the live skill-toggle journey rather than `claude plugin test`; native +component tests stub the subagent routing process protocol. No component check asserts that the launch paths withdraw the skills. The TypeScript launch test also checks event/result forwarding. Separate cases cover denied-write rollback, invalid request bodies, disabled launches, and body-merge validation; diff --git a/tests/integration/test_ug_smart_routing_hooks.py b/tests/integration/test_ug_smart_routing_hooks.py index 01ed497a6..b20a928bc 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. @@ -63,9 +63,34 @@ 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 _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 SMART_ROUTING_SUBAGENT_NOTICE not in line: continue @@ -98,12 +123,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) @@ -113,15 +139,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: ( + f"SelectedModel:{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, @@ -229,13 +269,14 @@ 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``. - - 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, displays the routed agent's name, and audits one decision naming an offered - model for the session. Only the hook contract is asserted; no agent decides to spawn. + """Scenario: the Claude mod's routing CLI receives an Agent call under legacy + subagent routing or each supported selector. + + Expected: the shared Python client calls the real router and returns model, + rationale, and an updated input naming the dispatched ``ucode-route-`` agent while + preserving the task, so the mod labels the row with the agent that actually runs. + 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: @@ -254,7 +295,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)), @@ -262,19 +303,14 @@ 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 assert updated["description"] == payload["tool_input"]["description"], updated - for notice in (output["systemMessage"], hook["permissionDecisionReason"]): - text = "".join(notice.replace("│", "").split()) - assert f"Subagent:{updated['subagent_type']}" in text, output - assert "general-purpose" not in notice, output + assert "general-purpose" not in json.dumps(output), 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}" @@ -286,6 +322,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 @@ -394,10 +433,9 @@ def test_smart_router_skill_toggles_claude_subagent_routing( their recorded inference payloads carry smart_router_recipe_name with the launch recipe, DISABLED, then the launch recipe again, without a recipe header, 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..6b8612af8 --- /dev/null +++ b/tests/native_claude/mod_tests/subagent-routing.test.ts @@ -0,0 +1,131 @@ +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: ' ↳ Selected Model: ' })).toBeDefined(); + expect(await a.find({ type: 'Text', text: 'claude-sonnet-5' })).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(); + 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: /Selected for call-b/ })).toBeDefined(); + expect(await b.find({ type: 'Text', text: /ug-smart-router:/ })).toBeUndefined(); + 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(); + 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; + // 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(); + // 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/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 a3ef2f719..5e924846a 100644 --- a/tests/test_claude_mod.py +++ b/tests/test_claude_mod.py @@ -15,7 +15,13 @@ 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" # The entry point imports these, so they ship beside it unmodified. - for name in ("register.ts", "constants.ts", "extra-body.ts", "smart-routing-status.ts"): + for name in ( + "register.ts", + "constants.ts", + "extra-body.ts", + "smart-routing-status.ts", + "subagent-routing.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", @@ -23,6 +29,7 @@ def test_plugin_contains_mod_entry_point(tmp_path): "hooks.json", "register.ts", "smart-routing-status.ts", + "subagent-routing.ts", ] @@ -43,6 +50,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 +63,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 1564f2dd5..fbc508b87 100644 --- a/tests/test_claude_smart_routing_v2.py +++ b/tests/test_claude_smart_routing_v2.py @@ -323,6 +323,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" + 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"] @@ -330,16 +331,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", @@ -431,6 +436,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) @@ -501,7 +507,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" @@ -511,7 +528,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"] @@ -665,7 +682,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": { @@ -692,25 +709,67 @@ 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" ) - expected_message = ( - "\n┌───────────────────────────────────────────────────────────────────────────┐\n" - "│ Using Unity Gateway Smart Router - Subagent │\n" - "│ Subagent : ug-smart-router:ucode-route-claude-opus-4-8-3ec612f4 │\n" - "│ Selected Model : system.ai.claude-opus-4-8 │\n" - "└───────────────────────────────────────────────────────────────────────────┘" - ) - assert output["systemMessage"] == expected_message - assert output["hookSpecificOutput"]["permissionDecisionReason"] == expected_message decision_record = json.loads(decisions_path.read_text()) assert decision_record["requested_model"] == "system.ai.claude-opus-4-8" + 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, + "select_route", + lambda *a, **k: ( + routing.RoutingDecision( + model="system.ai.claude-opus-4-8", + raw_model="claude-opus-4-8", + rationale="A focused task with an explicit target", + ), + None, + ), + ) + + output = v2.route_claude_subagent( + { + "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"], + ) + + 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_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"}]}, + ] + } + } + + claude_hooks.sync_smart_routing_hooks(settings, {"workspace": "https://x"}, enabled=True) + + assert settings["hooks"]["PreToolUse"] == [{"hooks": [{"command": "user-policy"}]}] + assert "session-start" in str(settings["hooks"]["SessionStart"]) + assert "record-subagent" in str(settings["hooks"]["SubagentStart"]) + @pytest.mark.parametrize("orchestrator_enabled", ["0", "1"]) - def test_routed_agent_notice_matches_executed_agent( + def test_routed_agent_details_match_executed_agent( self, tmp_path, monkeypatch, orchestrator_enabled ): session_file = tmp_path / "env.json" @@ -722,7 +781,7 @@ def test_routed_agent_notice_matches_executed_agent( selected_model = "system.ai.claude-haiku-5-5" requested_agent = v2._routed_claude_agent_name("system.ai.glm-5-3-flash") routed_agent = v2._routed_claude_agent_name(selected_model) - # Control only the remote router response; exercise the hook and notice together. + # Control only the remote router response; exercise routing and the mod details together. monkeypatch.setattr( v2, "_request_claude_routing_decision", @@ -731,23 +790,35 @@ def test_routed_agent_notice_matches_executed_agent( None, ), ) - output = v2.route_claude_pre_tool_use( - { - "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, + ) - hook = output["hookSpecificOutput"] - assert hook["updatedInput"]["subagent_type"] == routed_agent - for notice in (output["systemMessage"], hook["permissionDecisionReason"]): + # The mod labels the row from updatedInput, so it names the dispatched agent. + 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(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") 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 64a2a9649..0db439900 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1149,15 +1149,17 @@ 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_pre_tool_use", + "ucode.cli.smart_routing_v2.route_claude_subagent", return_value=routed, ) as mock_v2_route, ): @@ -1177,7 +1179,59 @@ 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): + 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, + hook_output=False, + ) + else: + assert result.output == "" + route.assert_not_called() class TestManagedConfigLaunchSourceGuard: