Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/claude-mod.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/ucode/agents/claude_mods/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
7 changes: 3 additions & 4 deletions src/ucode/agents/claude_mods/register.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EngineInterface, On, PluginOptions } from 'claude-code';
import { registerStatusBand } from './smart-routing-status.ts';
import {
CLI_AGENT,
CLI_MODULE,
Expand Down Expand Up @@ -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);
}
62 changes: 62 additions & 0 deletions src/ucode/agents/claude_mods/smart-routing-status.ts
Original file line number Diff line number Diff line change
@@ -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<RoutingState> {
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 })
})
}
2 changes: 1 addition & 1 deletion src/ucode/smart_routing/v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]})

Expand Down
4 changes: 4 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions tests/e2e_cuj/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 29 additions & 1 deletion tests/e2e_cuj/test_cuj4_smart_routing.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Smart-routing CUJ: execute once, then report each contract assertion separately."""

import re
from dataclasses import dataclass

import pytest
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions tests/integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/test_ug_smart_routing_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
5 changes: 3 additions & 2 deletions tests/test_claude_mod.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]


Expand Down
21 changes: 20 additions & 1 deletion tests/test_cuj_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
7 changes: 7 additions & 0 deletions tests/test_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading