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 47c7ef188..2a4e3e03a 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 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 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 9153b7d0b..a6ab95fb1 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 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 b09163178..5a4734a05 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,15 @@ 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" + 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) class SessionCase: agent: str @@ -247,7 +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. + 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") @@ -277,6 +288,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 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 +386,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 {'ON' if enabled else '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 81454fe55..b2301e728 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1010,6 +1010,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 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 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..b18a45e3d 100644 --- a/tests/test_cuj_evidence.py +++ b/tests/test_cuj_evidence.py @@ -19,11 +19,30 @@ 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 +@pytest.mark.parametrize("enabled, first_prompt", [(True, True), (True, False), (False, False)]) +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'" + ) + 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) + 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) + + def records(agent, task, model): if agent == CLAUDE: return [ 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