Files
claude/mods/model-router/hooks/register.test.ts
T
bchanot 22455c051f feat(model-router): wave 3-A — first-use route confirmation, decision memory, project exceptions
routing.json (tracked, reached through the plugin directory) is now the
single source of the phase table and of every skill/agent row, plus the
decisions: confirmed rows/phases, changed rows (from/to) and projects
exceptions keyed by a normalized git remote (credentials never stored, no
machine paths). First use of a rowed typed skill, a rowed agent spawn or a
main-loop phase opens the engine's dialog (Later / Keep / two alternative
phases; Other = a phase name); a change asks Everywhere or This project
only. One dialog at a time, never in headless, never inside an agent,
never written by the model: only a dialog answer or /route ask writes,
serialized, size-capped, never creating the file. Layers: routing.json <
~/.claude/model-router.json; the project tree is never read. /route
pending, /route ask on|off. Census reads rows and phases from the file and
tolerates a user-changed row (WARN). Kit suite 86 → 190 tests.

Contract .claude/tasks/contracts/2026-10-10-model-router-w3a-confirm-1201.md,
plan r4: 3 lenses + 1 confirmation, feater + 4 rounds, GATE 0 MET,
verifier CONFORME then re-verify after security, security BLOCK(1) fixed
then PASS. Live: T2 dialogs answered by the user from the hot-loaded mod.
2026-10-11 11:48:25 +02:00

2544 lines
81 KiB
TypeScript

import { test, expect, mock } from 'claude-code/testing'
import type { Engine, MockClock, Plugin } from 'claude-code/testing'
import type { On, TurnStepInput } from 'claude-code'
const FABLE = 'claude-fable-5-1'
const OPUS = 'claude-opus-5-5'
const SONNET = 'claude-sonnet-5-5'
const HAIKU = 'claude-haiku-4-5-20251001'
type Rec = Record<string, unknown>
type Asked = { text: string; options: string[]; header: string }
const HOME = '/home/t'
const HOME_FILE = `${HOME}/.claude/model-router.json`
const ROOT = '/work/app'
const REAL_ROOT = '/real/work/app'
const REMOTE = 'https://github.com/acme/app.git'
const KEY = 'github.com/acme/app'
const REJECT = '<rejected>' // an answer the dialog never gets: headless
/** What the bottom `fs`, `env`, `session.repo` and dialog hooks serve. */
type World = {
files: Map<string, string> // 'routing' (routing.json) or a full path
reads: string[] // every path fs.read got
written: string[] // every path fs.write got
asked: Asked[] // the dialogs, in order
answers: string[] // queued dialog answers (REJECT = headless)
gate: Promise<void> | undefined // holds a dialog open until it settles
writeGate: Promise<void> | undefined // holds an fs.write open
failWrite: boolean // fs.write rejects
onAsk: (() => void) | undefined // called when a dialog opens
onWrite: (() => void) | undefined // called when an fs.write starts
repo: { root: string; remote: string | null } | null | 'fail'
}
type Fixture = {
ask?: boolean // routing.json `ask` (default false: no dialog)
edit?: (file: Rec) => void // tweaks the inline routing.json
routing?: 'absent' | 'broken'
home?: Rec | 'broken' // ~/.claude/model-router.json
answers?: string[]
repo?: World['repo']
}
/**
* The phases as shipped and the rows the tests use; the real table is
* locked by the census.
*/
const baseRouting = (): Rec => ({
phases: {
plan: { tier: 'best', effort: 'xhigh' },
reflect: { tier: 'best', effort: 'high' },
orchestrate: { tier: 'best', effort: 'medium' },
escalate: { tier: 'best', effort: 'max' },
judge: { tier: 'big', effort: 'xhigh' },
implement: { tier: 'work', effort: 'medium' },
write: { tier: 'work', effort: 'high' },
verify: { tier: 'work', effort: 'xhigh' },
explore: { tier: 'work', effort: 'medium' },
apply: { tier: 'work', effort: 'low' },
mechanical: { tier: 'cheap', effort: 'low' },
},
skills: {
feat: 'reflect',
status: 'mechanical',
gitflow: 'implement',
'using-git-worktrees': 'mechanical',
},
agents: {
Explore: 'explore',
'plan-challenger': 'judge',
feater: 'implement',
verifier: 'verify',
},
projects: {},
confirmed: {},
changed: {},
ask: false,
})
function newWorld(fx: Fixture = {}): World {
const file = baseRouting()
file.ask = fx.ask ?? false
fx.edit?.(file)
const files = new Map<string, string>()
if (fx.routing === 'broken') files.set('routing', '{nope')
else if (fx.routing !== 'absent') {
files.set('routing', JSON.stringify(file, null, 2))
}
if (fx.home !== undefined) {
files.set(HOME_FILE,
fx.home === 'broken' ? '{nope' : JSON.stringify(fx.home))
}
return {
files,
reads: [],
written: [],
asked: [],
answers: [...(fx.answers ?? [])],
gate: undefined,
writeGate: undefined,
failWrite: false,
onAsk: undefined,
onWrite: undefined,
repo: fx.repo === undefined ? { root: ROOT, remote: REMOTE } : fx.repo,
}
}
const slot = (path: string): string =>
path.endsWith('/routing.json') ? 'routing' : path
/** routing.json as the world holds it now. */
const routingOf = (w: World): Rec =>
JSON.parse(w.files.get('routing') ?? '{}') as Rec
/**
* A path-aware fs, HOME, project root and repo: the kit has none (an fs call
* no hook answers is refused, never sent to the disk: see "writes: kit").
*/
function installWorld(on: On, w: World): void {
on('env.get', () => ({ value: HOME }))
on('fs.exists', (_a, e) => ({
value: w.files.has(slot(e.path)) || e.path === ROOT,
}))
on('fs.stat', (_a, e) => {
const text = w.files.get(slot(e.path))
if (text === undefined && e.path !== ROOT) throw new Error('ENOENT')
return {
value: {
kind: text === undefined ? 'dir' as const : 'file' as const,
size: text?.length ?? 0,
mtimeMs: 0,
isLink: false,
...(e.resolve ? { realPath: REAL_ROOT } : {}),
},
}
})
on('fs.read', (_a, e) => {
w.reads.push(e.path)
const text = w.files.get(slot(e.path))
if (text === undefined) throw new Error('ENOENT')
return { value: text }
})
installWrite(on, w)
on('session.root', () => ({ value: ROOT }))
on('session.repo', () => {
if (w.repo === 'fail') throw new Error('no repo')
return { value: w.repo === null ? null : {
...w.repo, internal: false, name: null,
} }
})
on('classic.CwdChanged', () => ({}))
installDialog(on, w)
}
/**
* The world's fs.write. Loud: a write anywhere but the routing.json the mod
* read fails the test (never a project tree, never the machine override).
*/
function installWrite(on: On, w: World): void {
on('fs.write', async (_a, e) => {
expect(slot(e.path) === 'routing' && w.reads.includes(e.path),
`the world never served ${e.path}`).toBe(true)
w.written.push(e.path)
w.onWrite?.()
await w.writeGate
if (w.failWrite) throw new Error('EACCES')
w.files.set(slot(e.path), e.text)
return { value: undefined }
})
}
/** The AskUserQuestion tool beneath `$.ui.ask`: records, then answers. */
function installDialog(on: On, w: World): void {
on('tool.call', { tool: 'AskUserQuestion' }, async (_a, e) => {
const q = (e as unknown as { questions: Rec[] }).questions[0] ?? {}
const options = (q.options as { label: string }[] | undefined) ?? []
w.asked.push({
text: String(q.question),
options: options.map(o => o.label),
header: String(q.header),
})
w.onAsk?.()
await w.gate
const answer = w.answers.shift() ?? REJECT
if (answer === REJECT) return { deny: 'no one to ask' }
return { result: {
questions: e.questions, answers: { [String(q.question)]: answer },
} } as never
})
}
/**
* Fires session.start so the mod loads its config and registers /route.
* The bottom hooks the router reads are in place before the first `$` call:
* the session model, the classic failure and switch events, a mock clock,
* a small context (`tokens`; null = usage unanswered, size unknown), and
* the `world` (fs, repo, dialog) serving an inline routing.json.
*/
async function boot(
$: Engine,
on: On,
model: string = FABLE,
tokens: number | null = 1000,
world: World = newWorld(),
): Promise<MockClock> {
const clock = mock.clock(on)
on('session.model', () => ({ value: model }))
on('classic.StopFailure', () => ({}))
on('classic.PostModelSwitch', () => ({}))
on('skill.prompt', ($, e) => ({ text: e.text }))
on('agent.offer', () => ({ isOffered: true }))
installWorld(on, world)
if (tokens !== null) usageOf(on, tokens)
on('session.start', ($, e) => ({ cwd: e.cwd }))
await $.session.start({ cwd: '/tmp', surface: null, isInteractive: false })
return clock
}
/** Runs `/route <args>` as the user would type it. */
async function route($: Engine, args: string): Promise<string> {
const out = await $.command.run({
command: 'route',
args,
origin: { kind: 'composer' },
presentation: { isFullscreen: false, columns: 80 },
})
return out.text ?? ''
}
/** The `main:` line of `show`: the phases listing holds every id and level. */
function mainLine(text: string): string {
return text.split('\n').find(l => l.startsWith('main:')) ?? ''
}
const spawnInput = (model?: string) => ({
tool_use_id: 't1',
prompt: 'x',
description: 'd',
subagentType: 'Explore',
provider: { plugin: 'engine', tier: 'core' as const },
parentModel: 'claude-fable-5-1',
background: false,
fork: false,
...(model === undefined ? {} : { model }),
})
test('route tool with phase orchestrate sets medium on main', async (
$, on) => {
await boot($, on)
const out = await $.tool.call({
tool: 'mcp__model-router__route',
phase: 'orchestrate',
})
expect(out).toHaveProperty('result')
const line = mainLine(await route($, 'show'))
expect(line).toContain('model orchestrate')
expect(line).toContain('effort medium')
})
test('/route clear drops the route', async ($, on) => {
await boot($, on)
await $.tool.call({ tool: 'mcp__model-router__route', phase: 'plan' })
expect(mainLine(await route($, 'show'))).toContain('model plan')
await route($, 'clear')
expect(mainLine(await route($, 'show'))).toContain('session defaults')
})
test('/route bogus names the phases', async ($, on) => {
await boot($, on)
const text = await route($, 'bogus')
expect(text).toContain('unknown')
for (const phase of ['plan', 'judge', 'explore', 'mechanical']) {
expect(text).toContain(phase)
}
})
test('ultrathink in a prompt sets a max floor on main', async ($, on) => {
on('prompt.submit', ($, e) => ({ text: e.text }))
await boot($, on)
await $.prompt.submit({
text: 'ultrathink please',
wait: false,
origin: { kind: 'composer' },
})
expect(mainLine(await route($, 'show'))).toContain('floor max')
})
test('/route model: alias resolved, id passed, typo refused', async (
$, on) => {
await boot($, on)
const alias = await route($, 'model=sonnet')
expect(mainLine(alias)).toContain('asked claude-sonnet-5-5, keeps ')
const id = await route($, 'model=claude-x-9')
expect(mainLine(id)).toContain('claude-x-9')
expect(await route($, 'model=sonet')).toContain('unknown')
})
test('Explore spawn gets the sonnet id; an explicit model wins', async (
$, on) => {
const seen: (string | undefined)[] = []
on('agent.spawn', ($, e) => {
seen.push(e.model)
return { model: e.model ?? e.parentModel, agentId: 'a1' }
})
await boot($, on)
await $.agent.spawn(spawnInput())
await $.agent.spawn(spawnInput('opus'))
expect(seen).toEqual(['claude-sonnet-5-5', 'opus'])
})
test('a sticky /route wins over a model-declared route', async ($, on) => {
await boot($, on)
await route($, 'judge')
const out = await $.tool.call({
tool: 'mcp__model-router__route',
phase: 'mechanical',
})
expect(JSON.stringify(out)).toContain('sticky')
expect(mainLine(await route($, 'show'))).toContain('user judge')
})
test('/route off makes the route tool a no-op', async ($, on) => {
await boot($, on)
await route($, 'off')
const out = await $.tool.call({
tool: 'mcp__model-router__route',
phase: 'plan',
})
expect(JSON.stringify(out)).toContain('off')
expect(mainLine(await route($, 'show'))).toContain('session defaults')
})
type Seen = { model: string; effort: unknown }
/** A bottom turn.step hook that records what reaches the model. */
function recordSteps(on: On, seen: Seen[]): void {
on('turn.step', async function* ($, e) {
seen.push({ model: e.model, effort: e.effort })
return {
turnId: e.turnId,
index: e.index,
answer: '',
toolUses: [],
stopReason: 'end_turn' as const,
usage: null,
}
})
}
const stepInput = (agentId?: string) => ({
turnId: 'u1',
index: 0,
model: 'claude-fable-5-1',
effort: 'low' as const,
messageCount: 1,
...(agentId === undefined ? {} : { agentId }),
})
/** Streams one turn.step to its end; the hooks run as the chunks flow. */
async function runStep(
$: Engine,
input: TurnStepInput,
): Promise<void> {
const stream = $.turn.step(input)
for await (const _chunk of stream) {
// chunks are not under test
}
await stream.result
}
test('the main step carries the routed effort, model untouched', async (
$, on) => {
const seen: Seen[] = []
recordSteps(on, seen)
await boot($, on)
await $.tool.call({ tool: 'mcp__model-router__route', phase: 'plan' })
await runStep($, stepInput())
expect(seen).toEqual([{ model: 'claude-fable-5-1', effort: 'xhigh' }])
})
test('a tabled agent steps at its table effort', async ($, on) => {
const seen: Seen[] = []
recordSteps(on, seen)
on('agent.spawn', ($, e) => ({
model: e.model ?? e.parentModel,
agentId: 'a1',
}))
await boot($, on)
await $.agent.spawn(spawnInput())
await runStep($, stepInput('a1'))
expect(seen).toEqual([{ model: 'claude-fable-5-1', effort: 'medium' }])
})
test('/route from a non-composer origin is refused, state kept', async (
$, on) => {
await boot($, on)
await route($, 'judge')
const out = await $.command.run({
command: 'route',
args: 'mechanical',
origin: { kind: 'plugin', name: 'x' },
presentation: { isFullscreen: false, columns: 80 },
})
expect(out.text).toContain('user-only')
expect(mainLine(await route($, 'show'))).toContain('user judge')
})
test('an in-agent route sets effort only, the model stays', async ($, on) => {
const seen: Seen[] = []
recordSteps(on, seen)
await boot($, on)
await $.tool.call({
tool: 'mcp__model-router__route',
phase: 'judge',
agentId: 'a1',
})
await runStep($, { ...stepInput('a1'), model: 'claude-sonnet-5-5' })
expect(seen).toEqual([{ model: 'claude-sonnet-5-5', effort: 'xhigh' }])
})
test('a rule only scans the first 4096 chars of a prompt', async ($, on) => {
on('prompt.submit', ($, e) => ({ text: e.text }))
await boot($, on)
await $.prompt.submit({
text: 'x'.repeat(5000) + ' ultrathink',
wait: false,
origin: { kind: 'composer' },
})
expect(mainLine(await route($, 'show'))).toContain('session defaults')
})
// ---- user effort floor -------------------------------------------------
const ROUTE_TOOL = 'mcp__model-router__route'
/** A main (or agent) step as the engine would make it: engine effort high. */
const highStep = (agentId?: string): TurnStepInput => ({
...stepInput(agentId),
effort: 'high' as const,
})
/** Types `ultrathink` in the composer, idle or over a running turn. */
async function ultrathink($: Engine, turnId?: string): Promise<void> {
await $.prompt.submit({
text: 'ultrathink please',
wait: turnId !== undefined,
origin: { kind: 'composer' },
...(turnId === undefined ? {} : { turnId }),
})
}
/** Ends a main turn: the floor's life is bounded by this event. */
async function endTurn($: Engine): Promise<void> {
await $.turn.complete({
turnId: 'u1',
answer: '',
durationMs: 1,
isAborted: false,
reason: 'answer',
})
}
/** One main step; returns the effort that reached the bottom hook. */
async function stepEffort($: Engine, seen: Seen[]): Promise<unknown> {
await runStep($, highStep())
return seen[seen.length - 1]?.effort
}
/** Boots with a bottom step recorder and prompt/turn hooks in place. */
async function bootFloor($: Engine, on: On): Promise<Seen[]> {
const seen: Seen[] = []
recordSteps(on, seen)
on('prompt.submit', ($, e) => ({ text: e.text }))
on('turn.complete', () => ({ text: '' }))
on('tool.call', { tool: 'Skill' }, () => ({
result: { success: true, commandName: 'other' },
}))
on('agent.spawn', ($, e) => ({
model: e.model ?? e.parentModel,
agentId: 'a1',
}))
await boot($, on)
return seen
}
test('floor: ultrathink survives a model route', async ($, on) => {
const seen = await bootFloor($, on)
await ultrathink($)
await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' })
expect(await stepEffort($, seen)).toBe('max')
})
test('floor: survives a skill load; an unrowed skill keeps the route', async (
$, on) => {
const seen = await bootFloor($, on)
await ultrathink($)
await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' })
await $.tool.call({ tool: 'Skill', skill: 'other' })
expect(await stepEffort($, seen)).toBe('max')
expect(mainLine(await route($, 'show'))).toContain('model orchestrate')
})
test('floor: lifts a lower sticky, then ends with the turn', async (
$, on) => {
const seen = await bootFloor($, on)
await route($, 'effort=low')
await ultrathink($)
expect(await stepEffort($, seen)).toBe('max')
await endTurn($)
expect(await stepEffort($, seen)).toBe('low')
})
test('floor: main only, an agent step is unaffected', async ($, on) => {
const seen = await bootFloor($, on)
await $.agent.spawn(spawnInput())
await ultrathink($)
await runStep($, highStep('a1'))
expect(seen[0]?.effort).toBe('medium')
})
test('floor: /route clear removes it', async ($, on) => {
const seen = await bootFloor($, on)
await ultrathink($)
expect(mainLine(await route($, 'show'))).toContain('floor max')
await route($, 'clear')
expect(await stepEffort($, seen)).toBe('high')
expect(mainLine(await route($, 'show'))).not.toContain('floor')
})
test('floor: a mid-turn prompt applies now and is kept for the next', async (
$, on) => {
const seen = await bootFloor($, on)
await ultrathink($, 'u1')
expect(await stepEffort($, seen)).toBe('max')
await endTurn($)
expect(await stepEffort($, seen)).toBe('max')
await endTurn($)
expect(await stepEffort($, seen)).toBe('high')
})
test('floor: the route answer names the floor over a sticky', async (
$, on) => {
await bootFloor($, on)
await route($, 'effort=low')
await ultrathink($)
const out = await $.tool.call({ tool: ROUTE_TOOL, phase: 'plan' })
const text = JSON.stringify(out)
expect(text).toContain('user floor max (prompt rule escalate)')
expect(text).toContain('/route clear')
})
// `enabled: false` in ~/.claude/model-router.json has its own layers test;
// this one covers the shared off path through `/route off`.
test('floor: /route off keeps the floor from routing', async ($, on) => {
const seen = await bootFloor($, on)
await ultrathink($)
await route($, 'off')
expect(await stepEffort($, seen)).toBe('high')
expect(mainLine(await route($, 'show'))).not.toContain('floor')
})
test('per axis: a model-only sticky keeps the turn route effort', async (
$, on) => {
const seen = await bootFloor($, on)
await route($, 'model=sonnet')
await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' })
expect(await stepEffort($, seen)).toBe('medium')
})
// The config-driven `offConfig` is re-applied in the same rebuild; only the
// session-only off is reached here (the config-driven one has its own test).
test('session.end rebuild drops a session-only /route off', async (
$, on) => {
on('session.end', ($, e) => ({ sessionId: e.sessionId }))
await boot($, on)
await route($, 'off')
await $.session.end({
reason: 'clear',
sessionId: 's1',
resume: { id: 's1' },
})
expect(await route($, 'show')).toContain('router: on')
})
// The override breaks after the first load: the previous cfg must stay.
test('reload with an unreadable override keeps the previous config', async (
$, on) => {
const world = newWorld({ home: {} })
await boot($, on, FABLE, 1000, world)
await route($, 'switch on')
world.files.set(HOME_FILE, '{nope')
const out = await route($, 'reload')
expect(out).toContain('switch: on')
expect(await route($, 'show')).toContain('switch: on')
})
// ---- tiers, breaker, derived phases -------------------------------------
type Rig = { seen: Seen[]; clock: MockClock; specs: (string | undefined)[] }
type Failure = 'rate_limit' | 'overloaded' | 'invalid_request'
/** Bottom hooks for a routed run, then the boot; `specs` records spawns. */
async function bootRig(
$: Engine,
on: On,
model: string = FABLE,
tokens: number | null = 1000,
world: World = newWorld(),
): Promise<Rig> {
const seen: Seen[] = []
const specs: (string | undefined)[] = []
recordSteps(on, seen)
on('prompt.submit', ($, e) => ({ text: e.text }))
on('turn.complete', () => ({ text: '' }))
on('agent.spawn', ($, e) => {
specs.push(e.model)
return { model: e.model ?? e.parentModel, agentId: 'a1' }
})
on('tool.call', { tool: 'Agent' }, () => ({
result: {
status: 'async_launched' as const,
agentId: 'a1',
description: 'd',
prompt: 'p',
outputFile: '/tmp/o',
},
}))
const clock = await boot($, on, model, tokens, world)
return { seen, clock, specs }
}
/** One main step on `model`; returns what reached the bottom hook. */
async function stepOn($: Engine, rig: Rig, model: string): Promise<Seen> {
await runStep($, { ...highStep(), model })
return rig.seen[rig.seen.length - 1] ?? { model: '', effort: undefined }
}
const failWith = ($: Engine, error: Failure, agentId?: string) =>
$.classic.StopFailure({
error,
...(agentId === undefined ? {} : { agent_id: agentId }),
})
const switchTo = (
$: Engine,
from: string,
to: string,
source: 'command' | 'auto',
) => $.classic.PostModelSwitch({
from_model: from,
to_model: to,
requested_model: null,
source,
context_tokens: 0,
prompt_cache_warm: false,
cache_ttl: '5m',
estimated_cache_write_usd: 0,
pricing: 'catalog',
})
const planRoute = ($: Engine) =>
$.tool.call({ tool: ROUTE_TOOL, phase: 'plan' })
/** A bottom `session.usage` answering a context of `tokens`. */
function usageOf(on: On, tokens: number): void {
on('session.usage', () => ({
value: {
startedAt: 0,
context: { window: 1000000, tokens },
rateLimits: [],
},
}))
}
test('tier: a plan route upgrades a haiku session to fable xhigh', async (
$, on) => {
const rig = await bootRig($, on, HAIKU)
await planRoute($)
expect(await stepOn($, rig, HAIKU)).toEqual({
model: FABLE,
effort: 'xhigh',
})
})
test('tier: show prints the resolved id of each phase and a down line', async (
$, on) => {
await bootRig($, on)
const text = await route($, 'show')
expect(text).toContain('plan=best→claude-fable-5-1/xhigh')
expect(text).toContain('judge=big→claude-opus-5-5/xhigh')
expect(text).toContain('down: none')
})
test('tier: an upgrade is skipped above the context cap', async ($, on) => {
const rig = await bootRig($, on, SONNET, 300000)
await planRoute($)
expect(await stepOn($, rig, SONNET)).toEqual({
model: SONNET,
effort: 'xhigh',
})
})
test('cap: unknown usage blocks the upgrade', async ($, on) => {
const rig = await bootRig($, on, HAIKU, null)
await planRoute($)
expect((await stepOn($, rig, HAIKU)).model).toBe(HAIKU)
})
test('cap: a down model leaves for a better wanted only under the cap', async (
$, on) => {
const rig = await bootRig($, on, OPUS, 300000)
await planRoute($)
await stepOn($, rig, OPUS)
await failWith($, 'rate_limit')
expect((await stepOn($, rig, OPUS)).model).toBe(SONNET)
})
test('breaker: model_not_found on a non-table id marks nothing', async (
$, on) => {
const rig = await bootRig($, on)
await planRoute($)
await stepOn($, rig, 'claude-zz-9')
await $.classic.StopFailure({ error: 'model_not_found' })
expect(await route($, 'show')).toContain('down: none')
})
test('downgrade: mechanical on fable keeps the model, switch off', async (
$, on) => {
const rig = await bootRig($, on)
await $.tool.call({ tool: ROUTE_TOOL, phase: 'mechanical' })
expect(await stepOn($, rig, FABLE)).toEqual({ model: FABLE, effort: 'low' })
})
test('downgrade: the switch on moves main to the cheap tier', async (
$, on) => {
const rig = await bootRig($, on)
await route($, 'switch on')
await $.tool.call({ tool: ROUTE_TOOL, phase: 'mechanical' })
const sent = await stepOn($, rig, FABLE)
expect(sent.model).toBe(HAIKU)
expect(sent.effort).toBeUndefined()
})
test('fallback: a rate limit on fable moves plan to opus xhigh', async (
$, on) => {
const rig = await bootRig($, on)
await planRoute($)
expect((await stepOn($, rig, FABLE)).model).toBe(FABLE)
await failWith($, 'rate_limit')
expect(await stepOn($, rig, FABLE)).toEqual({ model: OPUS, effort: 'xhigh' })
expect(await route($, 'show')).toContain('claude-fable-5-1 for 15 min')
await route($, 'reload')
expect((await stepOn($, rig, FABLE)).model).toBe(FABLE)
})
test('breaker: an invalid_request or a turn error never marks down', async (
$, on) => {
const rig = await bootRig($, on)
await planRoute($)
await stepOn($, rig, FABLE)
await failWith($, 'invalid_request')
await $.turn.complete({
turnId: 'u1',
answer: '',
durationMs: 1,
isAborted: false,
reason: 'error',
})
expect((await stepOn($, rig, FABLE)).model).toBe(FABLE)
expect(await route($, 'show')).toContain('down: none')
})
test('breaker: the hold lapses with the clock, a second one doubles', async (
$, on) => {
const rig = await bootRig($, on)
await planRoute($)
await stepOn($, rig, FABLE)
await failWith($, 'overloaded')
await rig.clock.advance(15 * 60000)
expect(await route($, 'show')).toContain('down: none')
await endTurn($)
await planRoute($)
expect((await stepOn($, rig, FABLE)).model).toBe(FABLE)
await failWith($, 'overloaded')
expect(await route($, 'show')).toContain('claude-fable-5-1 for 30 min')
})
test('breaker: a /model command clears the mark of its target', async (
$, on) => {
const rig = await bootRig($, on)
await planRoute($)
await stepOn($, rig, FABLE)
await failWith($, 'rate_limit')
expect(await route($, 'show')).toContain('claude-fable-5-1 for')
await switchTo($, OPUS, FABLE, 'command')
expect(await route($, 'show')).toContain('down: none')
expect((await stepOn($, rig, FABLE)).model).toBe(FABLE)
})
test('breaker: model_not_found holds until the reload', async ($, on) => {
const rig = await bootRig($, on)
await planRoute($)
await stepOn($, rig, FABLE)
await $.classic.StopFailure({ error: 'model_not_found' })
await rig.clock.advance(24 * 3600000)
expect(await route($, 'show')).toContain('until reload (model_not_found)')
})
test('engine fallback: an auto switch marks the model it left, once', async (
$, on) => {
const rig = await bootRig($, on)
await switchTo($, FABLE, OPUS, 'auto')
const first = await route($, 'show')
expect(first).toContain('claude-fable-5-1 for 15 min (engine fallback)')
await planRoute($)
expect((await stepOn($, rig, OPUS)).model).toBe(OPUS)
await rig.clock.advance(60000)
await switchTo($, FABLE, OPUS, 'auto')
expect(await route($, 'show')).toContain('claude-fable-5-1 for 14 min')
})
test('unknown: a session model absent from the table is never switched', async (
$, on) => {
const rig = await bootRig($, on)
await planRoute($)
expect((await stepOn($, rig, 'claude-zz-9')).model).toBe('claude-zz-9')
})
test('spawn: Explore goes to opus while sonnet is down', async ($, on) => {
const rig = await bootRig($, on)
await $.agent.spawn(spawnInput())
await failWith($, 'overloaded', 'a1')
await $.agent.spawn(spawnInput())
expect(rig.specs).toEqual([SONNET, OPUS])
})
/** Types a plain prompt, idle, in the composer. */
const typed = ($: Engine, text: string) => $.prompt.submit({
text,
wait: false,
origin: { kind: 'composer' },
})
const dispatch = ($: Engine) =>
$.tool.call({ tool: 'Agent', description: 'd', prompt: 'p' })
const agentEnds = ($: Engine) => $.turn.complete({
turnId: 'a1',
agentId: 'a1',
answer: '',
durationMs: 1,
isAborted: false,
reason: 'answer',
})
test('derived: a dispatch pushes orchestrate, the end pops the prompt', async (
$, on) => {
await bootRig($, on)
await typed($, 'planifie la migration')
expect(mainLine(await route($, 'show'))).toContain('prompt plan')
await dispatch($)
expect(mainLine(await route($, 'show'))).toContain('derived orchestrate')
await agentEnds($)
expect(mainLine(await route($, 'show'))).toContain('prompt plan')
})
test('derived: a route declared after the dispatch survives the pop', async (
$, on) => {
await bootRig($, on)
await typed($, 'planifie la migration')
await dispatch($)
await $.tool.call({ tool: ROUTE_TOOL, phase: 'reflect' })
await agentEnds($)
expect(mainLine(await route($, 'show'))).toContain('model reflect')
})
test('default rule: planifie sets plan, a later route overrides', async (
$, on) => {
const rig = await bootRig($, on, HAIKU)
await typed($, 'planifie la migration')
expect(await stepOn($, rig, HAIKU)).toEqual({ model: FABLE, effort: 'xhigh' })
await $.tool.call({ tool: ROUTE_TOOL, phase: 'implement' })
const line = mainLine(await route($, 'show'))
expect(line).toContain('model implement')
expect(line).not.toContain('prompt')
})
test('default rule: a typed slash command gets no default rule', async (
$, on) => {
await bootRig($, on)
await typed($, '/analyze pourquoi ça plante')
expect(mainLine(await route($, 'show'))).toContain('session defaults')
})
test('per axis: a model-less sticky never hides a turn route tier', async (
$, on) => {
const rig = await bootRig($, on, HAIKU)
await route($, 'effort=low')
await planRoute($)
expect(await stepOn($, rig, HAIKU)).toEqual({ model: FABLE, effort: 'low' })
})
// ---- texts follow the decision, from the turn's model ------------------
test('text: effort-only route on a down model says fallback', async (
$, on) => {
const rig = await bootRig($, on)
await stepOn($, rig, FABLE)
await failWith($, 'rate_limit')
const out = await $.tool.call({ tool: ROUTE_TOOL, effort: 'low' })
expect(JSON.stringify(out)).toContain(`model ${OPUS} (fallback)`)
})
test('text: /route effort= on a down model names the fallback', async (
$, on) => {
const rig = await bootRig($, on)
await stepOn($, rig, FABLE)
await failWith($, 'rate_limit')
await route($, 'effort=low')
expect(mainLine(await route($, 'show'))).toContain(`${OPUS} (fallback)`)
})
test('text: show names the session model after the turn ended', async (
$, on) => {
const rig = await bootRig($, on, HAIKU)
await planRoute($)
expect((await stepOn($, rig, HAIKU)).model).toBe(FABLE)
await endTurn($)
await route($, 'mechanical')
const line = mainLine(await route($, 'show'))
expect(line).toContain(`model ${HAIKU}`)
expect(line).not.toContain(FABLE)
})
test('text: show reflects a /model command to opus', async ($, on) => {
const rig = await bootRig($, on)
await stepOn($, rig, FABLE)
await switchTo($, FABLE, OPUS, 'command')
await route($, 'mechanical')
const line = mainLine(await route($, 'show'))
expect(line).toContain(OPUS)
expect(line).not.toContain(FABLE)
})
test('text: an unknown session model prints "session model"', async (
$, on) => {
await bootRig($, on, 'mystery-model')
await route($, 'plan')
expect(mainLine(await route($, 'show'))).toContain('keeps session model')
})
test('text: show names the model a floor upgrade moves to', async (
$, on) => {
await bootRig($, on, HAIKU)
await $.prompt.submit({
text: 'ultrathink please',
wait: false,
origin: { kind: 'composer' },
})
const line = mainLine(await route($, 'show'))
expect(line).toContain(`${FABLE} (upgrade)`)
})
// ---- wave 2: skill rows, run slot, agent rows ---------------------------
const skillPrompt = ($: Engine, skill: string) =>
$.skill.prompt({ skill, text: 'x' })
const loadSkill = ($: Engine, skill: string, agentId?: string) =>
$.tool.call({
tool: 'Skill',
skill,
...(agentId === undefined ? {} : { agentId }),
})
/** bootRig plus a bottom Skill tool, so a model-loaded skill goes through. */
async function bootRun(
$: Engine,
on: On,
model: string = FABLE,
world: World = newWorld(),
) {
on('tool.call', { tool: 'Skill' }, () => ({
result: { success: true, commandName: 'bottom' },
}))
return bootRig($, on, model, 1000, world)
}
const spawnOf = (agent: string, extra: object = {}) =>
({ ...spawnInput(), subagentType: agent, ...extra })
const offerOf = ($: Engine, agent: string, source: string) =>
$.agent.offer({
agent,
description: 'd',
source,
provider: { plugin: 'engine', tier: 'core' },
})
/**
* A ui.log recorder. The kit refuses a bottom hook for a void event, so the
* hook sits on top and swallows the missing implementation below it.
*/
function recordLogs(on: On): string[] {
const lines: string[] = []
on('ui.log', async ($, e, next) => {
lines.push(e.text)
try {
await next(e)
} catch {
// nothing implements ui.log below the recorder in the kit
}
})
return lines
}
test('typed /feat routes main to its row, best tier', async ($, on) => {
await bootRun($, on)
await typed($, '/feat add a thing')
await skillPrompt($, 'feat')
const line = mainLine(await route($, 'show'))
expect(line).toContain('skill reflect')
expect(line).toContain('effort high')
expect(line).toContain('[tier best]')
})
test('typed slash: a foreign origin arms nothing, fallback refused', async (
$, on) => {
await bootRun($, on)
await $.prompt.submit({
text: '/feat x',
wait: false,
origin: { kind: 'channel', server: 'slack' },
})
await skillPrompt($, 'feat')
expect(mainLine(await route($, 'show'))).toContain('session defaults')
})
test('typed slash: a preload in a live sub-agent leaves main alone', async (
$, on) => {
await bootRun($, on)
await typed($, 'hello')
await $.agent.spawn(spawnOf('feater'))
await skillPrompt($, 'feat')
expect(mainLine(await route($, 'show'))).toContain('session defaults')
})
test('typed slash: no live loop and an allowed origin still routes', async (
$, on) => {
await bootRun($, on)
await typed($, 'hello')
await skillPrompt($, 'feat')
expect(mainLine(await route($, 'show'))).toContain('skill reflect')
})
test('typed slash: an sdk origin arms the marker', async ($, on) => {
await bootRun($, on)
await $.prompt.submit({
text: '/status',
wait: false,
origin: { kind: 'sdk' },
})
await $.agent.spawn(spawnOf('feater'))
await skillPrompt($, 'status')
expect(mainLine(await route($, 'show'))).toContain('skill mechanical')
})
test('typed slash: a mid-turn /status waits for the turn end', async (
$, on) => {
await bootRun($, on)
await $.prompt.submit({
text: '/status',
wait: true,
origin: { kind: 'composer' },
turnId: 'u1',
})
await skillPrompt($, 'status')
expect(mainLine(await route($, 'show'))).toContain('session defaults')
await endTurn($)
await skillPrompt($, 'status')
expect(mainLine(await route($, 'show'))).toContain('skill mechanical')
})
test('skill without a row keeps the route; a rowed one replaces it', async (
$, on) => {
await bootRun($, on)
await $.tool.call({ tool: ROUTE_TOOL, phase: 'plan' })
await loadSkill($, 'find-docs')
expect(mainLine(await route($, 'show'))).toContain('model plan')
await loadSkill($, 'gitflow')
expect(mainLine(await route($, 'show'))).toContain('skill implement')
})
test('run slot: survives route calls and the turn end', async ($, on) => {
await bootRun($, on)
await loadSkill($, 'feat')
await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' })
await endTurn($)
expect(mainLine(await route($, 'show'))).toContain('run reflect')
await $.tool.call({ tool: ROUTE_TOOL, phase: 'plan' })
await endTurn($)
expect(mainLine(await route($, 'show'))).toContain('run reflect')
})
test('run slot: a model-loaded helper skill keeps it', async ($, on) => {
await bootRun($, on)
await loadSkill($, 'feat')
await loadSkill($, 'using-git-worktrees')
expect(mainLine(await route($, 'show'))).toContain('skill mechanical')
await endTurn($)
expect(mainLine(await route($, 'show'))).toContain('run reflect')
})
test('run slot: a typed non-best skill drops it', async ($, on) => {
await bootRun($, on)
await loadSkill($, 'feat')
await endTurn($)
await typed($, '/status')
await skillPrompt($, 'status')
await endTurn($)
expect(mainLine(await route($, 'show'))).toContain('session defaults')
})
test('run slot: /route clear and /route off drop it', async ($, on) => {
await bootRun($, on)
await loadSkill($, 'feat')
expect(await route($, 'clear')).toContain('run slot dropped')
await endTurn($)
expect(mainLine(await route($, 'show'))).toContain('session defaults')
await loadSkill($, 'feat')
await route($, 'off')
await route($, 'on')
await endTurn($)
expect(mainLine(await route($, 'show'))).toContain('session defaults')
})
test('run slot: a user /model switch drops it, an auto one does not', async (
$, on) => {
await bootRun($, on)
await loadSkill($, 'feat')
await endTurn($)
await switchTo($, FABLE, OPUS, 'auto')
expect(mainLine(await route($, 'show'))).toContain('run reflect')
await switchTo($, FABLE, OPUS, 'command')
expect(mainLine(await route($, 'show'))).toContain('session defaults')
})
test('run slot: a Skill call inside a sub-agent never touches it', async (
$, on) => {
await bootRun($, on)
await loadSkill($, 'feat', 'a1')
await endTurn($)
expect(mainLine(await route($, 'show'))).toContain('session defaults')
})
test('run slot: route(clear) clears the turn and names the run', async (
$, on) => {
await bootRun($, on)
await loadSkill($, 'feat')
const out = await $.tool.call({ tool: ROUTE_TOOL, clear: true })
expect(JSON.stringify(out)).toContain('run reflect still holds')
})
test('route answer names the id even with a floor in force', async (
$, on) => {
await bootRun($, on)
await ultrathink($)
const out = await $.tool.call({ tool: ROUTE_TOOL, phase: 'plan' })
const text = JSON.stringify(out)
expect(text).toContain(`model ${FABLE}`)
expect(text).toContain('user floor max')
})
test('agent row: feater spawns on sonnet and steps at medium', async (
$, on) => {
const rig = await bootRun($, on)
const out = await $.agent.spawn(spawnOf('feater'))
expect(rig.specs).toEqual([SONNET])
expect(out.model).toBe(SONNET)
await runStep($, { ...highStep('a1'), model: SONNET })
expect(rig.seen[rig.seen.length - 1]).toEqual({
model: SONNET,
effort: 'medium',
})
})
/** Spawn feater, load a skill inside it, return the effort of its step. */
async function effortAfterSkill($: Engine, on: On, skill: string) {
const rig = await bootRun($, on)
await $.agent.spawn(spawnOf('feater'))
await loadSkill($, skill, 'a1')
await runStep($, { ...highStep('a1'), model: SONNET })
return rig.seen[rig.seen.length - 1].effort
}
test('agent skill: an unrowed one leaves the loop effort', async ($, on) => {
expect(await effortAfterSkill($, on, 'find-docs')).toBe('medium')
})
test('agent skill: a rowed one writes the loop effort', async ($, on) => {
expect(await effortAfterSkill($, on, 'status')).toBe('low')
})
test('agent row: judge moves up to fable when opus is down', async (
$, on) => {
const rig = await bootRun($, on)
await switchTo($, OPUS, SONNET, 'auto')
await $.agent.spawn(spawnOf('plan-challenger'))
expect(rig.specs).toEqual([FABLE])
})
test('agent row: opus and fable down, no write and one log line', async (
$, on) => {
const logs = recordLogs(on)
const rig = await bootRun($, on)
await switchTo($, OPUS, SONNET, 'auto')
await switchTo($, FABLE, SONNET, 'auto')
await $.agent.spawn(spawnOf('plan-challenger'))
await $.agent.spawn(spawnOf('plan-challenger'))
expect(rig.specs).toEqual([undefined, undefined])
const down = logs.filter(l => l.includes('frontmatter model kept'))
expect(down).toEqual([
'model-router: plan-challenger tier big down, frontmatter model kept',
])
})
test('agent row: explicit model, explicit effort and fork win', async (
$, on) => {
const rig = await bootRun($, on)
await $.agent.spawn(spawnOf('feater', { model: 'opus' }))
await $.agent.spawn(spawnOf('feater', { fork: true }))
expect(rig.specs).toEqual(['opus', undefined])
await $.tool.call({
tool: 'Agent',
description: 'd',
prompt: 'p',
effort: 'low',
tool_use_id: 't1',
})
await $.agent.spawn(spawnOf('feater'))
await runStep($, { ...highStep('a1'), model: SONNET })
expect(rig.seen[rig.seen.length - 1]?.effort).toBe('high')
})
test('agent row: a project definition keeps its own model', async ($, on) => {
const rig = await bootRun($, on)
await offerOf($, 'verifier', 'projectSettings')
await offerOf($, 'feater', 'userSettings')
await $.agent.spawn(spawnOf('verifier'))
await $.agent.spawn(spawnOf('feater'))
expect(rig.specs).toEqual([undefined, SONNET])
})
test('agent row: an override null drops the row', async ($, on) => {
const world = newWorld({ home: { agents: { verifier: null } } })
const rig = await bootRun($, on, FABLE, world)
await $.agent.spawn(spawnOf('verifier'))
await $.agent.spawn(spawnOf('feater'))
expect(rig.specs).toEqual([undefined, SONNET])
})
test('show lists the write and apply phases', async ($, on) => {
await bootRun($, on)
const text = await route($, 'show')
expect(text).toContain('apply=work→claude-sonnet-5-5/low')
expect(text).toContain('write=work→claude-sonnet-5-5/high')
})
// ---- wave 2: typed-slash origins, spawning counter, spawn exclusions ----
/** A promise the test settles by hand, to hold a hook in flight. */
function deferred() {
let release: () => void = () => {}
const gate = new Promise<void>(r => { release = r })
return { gate, release }
}
test('typed slash: an in-flight spawn refuses the fallback', async (
$, on) => {
const hold = deferred()
const entered = deferred()
on('prompt.submit', ($, e) => ({ text: e.text }))
on('agent.spawn', async ($, e) => {
entered.release()
await hold.gate
return { model: e.model ?? e.parentModel, agentId: 'a1' }
})
await boot($, on)
await typed($, 'hello')
const spawned = $.agent.spawn(spawnOf('feater'))
await entered.gate
await skillPrompt($, 'feat')
expect(mainLine(await route($, 'show'))).toContain('session defaults')
hold.release()
await spawned
})
test('typed slash: a bridge origin arms the marker', async ($, on) => {
await bootRun($, on)
await $.prompt.submit({
text: '/feat x',
wait: false,
origin: { kind: 'bridge' },
})
await $.agent.spawn(spawnOf('feater'))
await skillPrompt($, 'feat')
expect(mainLine(await route($, 'show'))).toContain('skill reflect')
})
test('typed slash: a promoted mid-turn marker routes past a live loop', async (
$, on) => {
await bootRun($, on)
await $.agent.spawn(spawnOf('feater'))
await $.prompt.submit({
text: '/status',
wait: true,
origin: { kind: 'composer' },
turnId: 'u1',
})
await endTurn($)
await skillPrompt($, 'status')
expect(mainLine(await route($, 'show'))).toContain('skill mechanical')
})
test('agent row: any provider plugin applies, a local definition not', async (
$, on) => {
const rig = await bootRun($, on)
const provider = { plugin: 'some-plugin', tier: 'core' as const }
await $.agent.spawn(spawnOf('feater', { provider }))
await offerOf($, 'verifier', 'localSettings')
await $.agent.spawn(spawnOf('verifier', { provider }))
expect(rig.specs).toEqual([SONNET, undefined])
})
test('agent row: a workflow spawn is untouched, model and effort', async (
$, on) => {
const rig = await bootRun($, on)
await $.agent.spawn(spawnOf('feater', { workflow: {} }))
expect(rig.specs).toEqual([undefined])
await runStep($, { ...highStep('a1'), model: SONNET })
expect(rig.seen[rig.seen.length - 1]?.effort).toBe('high')
})
test('typed slash: the marker binds to its skill name only', async (
$, on) => {
await bootRun($, on)
await typed($, '/status')
await $.agent.spawn(spawnOf('feater'))
await skillPrompt($, 'feat')
expect(mainLine(await route($, 'show'))).toContain('session defaults')
})
test('typed slash: the marker is one-shot', async ($, on) => {
await bootRun($, on)
await typed($, '/status')
await $.agent.spawn(spawnOf('feater'))
await skillPrompt($, 'status')
expect(mainLine(await route($, 'show'))).toContain('skill mechanical')
await route($, 'clear')
await skillPrompt($, 'status')
expect(mainLine(await route($, 'show'))).toContain('session defaults')
})
test('skill.prompt nested in a Skill call leaves the run slot', async (
$, on) => {
on('tool.call', { tool: 'Skill' }, async (_api, e) => {
await skillPrompt($, 'status')
return { result: { success: true, commandName: String(e.skill) } }
})
await bootRig($, on, FABLE)
await typed($, '/status')
await loadSkill($, 'feat')
await endTurn($)
expect(mainLine(await route($, 'show'))).toContain('run reflect')
})
/** Submits a prompt from `kind` over a running turn (mid-turn). */
const queued = ($: Engine, kind: string, text: string) =>
$.prompt.submit({
text,
wait: true,
origin: { kind },
turnId: 'u1',
})
test('idle fallback: a mid-turn foreign prompt never arms it', async (
$, on) => {
await bootRun($, on)
await queued($, 'channel', 'hello')
await endTurn($)
await skillPrompt($, 'status')
expect(mainLine(await route($, 'show'))).toContain('session defaults')
})
test('idle fallback: a mid-turn composer prompt promotes the allowance', async (
$, on) => {
await bootRun($, on)
await $.prompt.submit({
text: 'hello',
wait: false,
origin: { kind: 'channel' },
})
await queued($, 'composer', 'hello')
await endTurn($)
await skillPrompt($, 'status')
expect(mainLine(await route($, 'show'))).toContain('skill mechanical')
})
test('spawning: a finished spawn no longer counts as a live loop', async (
$, on) => {
await bootRun($, on)
await $.agent.spawn(spawnOf('feater'))
await agentEnds($)
await typed($, 'hello')
await skillPrompt($, 'status')
expect(mainLine(await route($, 'show'))).toContain('skill mechanical')
})
// ---- wave 3-A: routing.json layers ---------------------------------------
/** A ui.toast recorder, same shape as recordLogs. */
function recordToasts(on: On): string[] {
const lines: string[] = []
on('ui.toast', async ($, e, next) => {
lines.push(e.text)
try {
await next(e)
} catch {
// nothing implements ui.toast below the recorder in the kit
}
})
return lines
}
const projectRows = (agents: Rec, key: string = KEY) => (file: Rec) => {
file.projects = { [key]: { agents } }
}
/** The model a `feater` spawn reaches the bottom hook with. */
async function featerModel($: Engine, rig: Rig): Promise<string | undefined> {
await $.agent.spawn(spawnOf('feater'))
return rig.specs[rig.specs.length - 1]
}
test('layers: routing.json rows route a spawn', async ($, on) => {
const rig = await bootRun($, on)
expect(await featerModel($, rig)).toBe(SONNET)
})
test('layers: a projects[key] row beats the routing.json row', async (
$, on) => {
const w = newWorld({ edit: projectRows({ feater: 'judge' }) })
const rig = await bootRun($, on, FABLE, w)
expect(await featerModel($, rig)).toBe(OPUS)
})
test('layers: another repo key leaves the routing.json row', async (
$, on) => {
const w = newWorld({ edit: projectRows({ feater: 'judge' }, 'other/repo') })
const rig = await bootRun($, on, FABLE, w)
expect(await featerModel($, rig)).toBe(SONNET)
})
test('layers: the machine override beats the project row', async (
$, on) => {
const w = newWorld({
edit: projectRows({ feater: 'judge' }),
home: { agents: { feater: 'mechanical' } },
})
const rig = await bootRun($, on, FABLE, w)
expect(await featerModel($, rig)).toBe(HAIKU)
})
test('layers: a .claude/model-router.json in the project is never read', async (
$, on) => {
const w = newWorld()
const tree = `${ROOT}/.claude/model-router.json`
w.files.set(tree, '{"agents":{"feater":"judge"}}')
const rig = await bootRun($, on, FABLE, w)
expect(await featerModel($, rig)).toBe(SONNET)
expect(w.reads).not.toContain(tree)
})
test('layers: the machine override\'s ask:false is honored', async (
$, on) => {
const w = newWorld({ ask: true, home: { ask: false } })
await bootRun($, on, FABLE, w)
await typed($, '/feat x')
await skillPrompt($, 'feat')
expect(w.asked).toEqual([])
})
test('layers: a row on a phase the table lacks is ignored, logged', async (
$, on) => {
const logs = recordLogs(on)
const w = newWorld({
edit: f => { (f.agents as Rec).feater = 'ghost' },
})
const rig = await bootRun($, on, FABLE, w)
expect(await featerModel($, rig)).toBeUndefined()
expect(logs.join('\n')).toContain('config agents.feater ignored')
})
test('layers: an invalid routing.json phase falls back by name', async (
$, on) => {
const logs = recordLogs(on)
const w = newWorld({
edit: f => { f.phases = { reflect: { tier: 'nope' } } },
})
await bootRun($, on, FABLE, w)
const text = await route($, 'show')
expect(text).toContain('reflect=best→claude-fable-5-1/high')
expect(logs.join('\n')).toContain('config phases.reflect ignored')
})
test('layers: a valid routing.json phase replaces the default', async (
$, on) => {
const w = newWorld({
edit: f => { f.phases = { reflect: { tier: 'work', effort: 'low' } } },
})
await bootRun($, on, FABLE, w)
const text = await route($, 'show')
expect(text).toContain('reflect=work→claude-sonnet-5-5/low')
})
test('layers: a routing.json phase gone missing falls back, logged', async (
$, on) => {
const logs = recordLogs(on)
const w = newWorld({ edit: f => { delete (f.phases as Rec).reflect } })
await bootRun($, on, FABLE, w)
expect(await route($, 'show')).toContain('reflect=best→claude-fable-5-1/high')
expect(logs.filter(l => l.includes('lacks phases'))).toEqual([
'model-router: routing.json lacks phases reflect; code defaults kept',
])
})
test('layers: a row may name a phase only the override defines', async (
$, on) => {
const logs = recordLogs(on)
const w = newWorld({
edit: f => {
;(f.skills as Rec).feat = 'custom'
projectRows({ feater: 'custom' })(f)
},
home: { phases: { custom: { tier: 'cheap', effort: 'low' } } },
})
const rig = await bootRun($, on, FABLE, w)
await typeFeat($)
expect(await mainNow($)).toContain('skill custom')
expect(await featerModel($, rig)).toBe(HAIKU)
expect(logs.join('\n')).not.toContain('ignored')
})
test('layers: show names routing.json as the config', async ($, on) => {
await bootRun($, on)
expect(await route($, 'show')).toContain('config: routing.json')
})
test('layers: first load skips a broken override, keeps routing.json', async (
$, on) => {
const logs = recordLogs(on)
const w = newWorld({ home: 'broken' })
const rig = await bootRun($, on, FABLE, w)
expect(await featerModel($, rig)).toBe(SONNET)
expect(logs.join('\n')).toContain('model-router.json unreadable')
})
test('layers: a later broken override keeps the whole previous config', async (
$, on) => {
const w = newWorld({ home: { agents: { feater: 'mechanical' } } })
const rig = await bootRun($, on, FABLE, w)
w.files.set(HOME_FILE, '{nope')
expect(await route($, 'reload')).toContain('config reloaded')
expect(await featerModel($, rig)).toBe(HAIKU)
})
test('layers: a routing.json gone missing keeps the previous config', async (
$, on) => {
const w = newWorld()
const rig = await bootRun($, on, FABLE, w)
w.files.delete('routing')
await route($, 'reload')
expect(await featerModel($, rig)).toBe(SONNET)
})
test('layers: routing.json unreadable at start means defaults, no rows', async (
$, on) => {
const logs = recordLogs(on)
const w = newWorld({ routing: 'broken', ask: true })
const rig = await bootRun($, on, FABLE, w)
expect(await featerModel($, rig)).toBeUndefined()
expect(await route($, 'show')).toContain('plan=best→claude-fable-5-1/xhigh')
const said = logs.filter(l => l.includes('routing.json unreadable'))
expect(said).toHaveLength(1)
})
test('layers: routing.json missing at start logs once, no rows', async (
$, on) => {
const logs = recordLogs(on)
const w = newWorld({ routing: 'absent' })
const rig = await bootRun($, on, FABLE, w)
expect(await featerModel($, rig)).toBeUndefined()
expect(logs.filter(l => l.includes('routing.json missing'))).toHaveLength(1)
})
test('layers: session toggles survive a rebuild', async ($, on) => {
await bootRun($, on)
await route($, 'switch on')
await route($, 'verbose on')
const out = await route($, 'reload')
expect(out).toContain('switch: on')
expect(out).toContain('verbose: on')
})
test('layers: /clear keeps the config, the next prompt re-reads it', async (
$, on) => {
on('session.end', ($, e) => ({ sessionId: e.sessionId }))
const w = newWorld()
const rig = await bootRun($, on, FABLE, w)
await clearOf($)
const before = w.reads.length
expect(await featerModel($, rig)).toBe(SONNET)
expect(w.reads.length).toBe(before)
const file = routingOf(w)
projectRows({ feater: 'judge' })(file)
w.files.set('routing', JSON.stringify(file))
await typed($, 'hello')
expect(await featerModel($, rig)).toBe(OPUS)
})
test('layers: /clear swaps nothing when the re-read fails', async ($, on) => {
on('session.end', ($, e) => ({ sessionId: e.sessionId }))
const w = newWorld()
const rig = await bootRun($, on, FABLE, w)
await clearOf($)
w.files.delete('routing')
await typed($, 'hello')
expect(await featerModel($, rig)).toBe(SONNET)
})
test('layers: a state that never saw session.start loads on first use', async (
$, on) => {
const w = newWorld()
installWorld(on, w)
on('agent.offer', () => ({ isOffered: true }))
const specs: (string | undefined)[] = []
on('agent.spawn', ($, e) => {
specs.push(e.model)
return { model: e.model ?? e.parentModel, agentId: 'a1' }
})
await $.agent.spawn(spawnOf('feater'))
expect(specs).toEqual([SONNET])
})
test('layers: a cwd change re-reads the layers for the new repo', async (
$, on) => {
const w = newWorld({
edit: projectRows({ feater: 'judge' }, 'github.com/o/two'),
})
const rig = await bootRun($, on, FABLE, w)
expect(await featerModel($, rig)).toBe(SONNET)
w.repo = { root: '/work/two', remote: 'git@GitHub.com:o/two.git' }
await $.classic.CwdChanged({ old_cwd: ROOT, new_cwd: '/work/two' })
expect(await featerModel($, rig)).toBe(OPUS)
})
test('layers: no remote gives no project key, no local: string', async (
$, on) => {
const w = asks(['implement', 'This project only'],
{ repo: { root: ROOT, remote: null } })
await bootRun($, on, FABLE, w)
await typeFeat($)
expect(w.asked[1]?.options).toEqual(['Everywhere', 'Later'])
expect(w.written).toEqual([])
expect(w.files.get('routing')).not.toContain('local:')
expect(w.files.get('routing')).not.toContain(REAL_ROOT)
})
test('layers: a failing repo read skips only the project layer', async (
$, on) => {
const w = newWorld({ edit: projectRows({ feater: 'judge' }), repo: 'fail' })
const rig = await bootRun($, on, FABLE, w)
expect(await featerModel($, rig)).toBe(SONNET)
})
test('layers: a remote with credentials keys without them', async ($, on) => {
const w = newWorld({
edit: projectRows({ feater: 'judge' }),
repo: { root: ROOT, remote: 'https://me:tok@GitHub.com/acme/app/' },
})
const rig = await bootRun($, on, FABLE, w)
expect(await featerModel($, rig)).toBe(OPUS)
})
/** The project keys "This project only" stores for a remote. */
async function keyedBy($: Engine, on: On, remote: string) {
const w = asks(['implement', 'This project only'],
{ repo: { root: ROOT, remote } })
await bootRun($, on, FABLE, w)
await typeFeat($)
return { w, keys: Object.keys(routingOf(w).projects as Rec) }
}
for (const bad of [
'https://user:p@ss@Host.com/a/b.git',
'https://user:pa/ss@host/a/b.git',
]) {
test(`layers: credentials never become a key: ${bad}`, async ($, on) => {
const { w, keys } = await keyedBy($, on, bad)
expect(keys).toEqual([])
expect(w.asked[1]?.options).toEqual(['Everywhere', 'Later'])
expect(w.files.get('routing')).not.toContain('ss@')
})
}
/** Remotes each rejected by exactly one guard of normalizeRemote. */
for (const [guard, bad] of [
['path @', 'https://user:12/ss@host/a/b.git'],
['empty host', 'file:///srv/git/app.git'],
['host [@:]', 'https://[::1]/a/b'],
['non-URL', 'https:host/a/b'],
['non-URL', 'weird remote'],
] as const) {
test(`layers: ${guard} guard rejects ${bad}`, async ($, on) => {
const { w, keys } = await keyedBy($, on, bad)
expect(keys).toEqual([])
expect(w.asked[1]?.options).toEqual(['Everywhere', 'Later'])
expect(w.files.get('routing')).not.toContain('/srv/')
expect(w.files.get('routing')).not.toContain('ss@')
})
}
for (const [remote, key] of [
['https://tok@host/a/b', 'host/a/b'],
['git@github.com:acme/app.git', KEY],
[REMOTE, KEY],
] as const) {
test(`layers: ${remote} keys as ${key}`, async ($, on) => {
expect((await keyedBy($, on, remote)).keys).toEqual([key])
})
}
// ---- wave 3-A: first-use dialog -------------------------------------------
/** A world with dialogs on and the answers queued. */
const asks = (answers: string[], fx: Fixture = {}) =>
newWorld({ ask: true, answers, ...fx })
/** The user types /feat; the engine then loads the skill's prompt. */
async function typeFeat($: Engine): Promise<void> {
await typed($, '/feat add a thing')
await skillPrompt($, 'feat')
}
const mainNow = async ($: Engine) => mainLine(await route($, 'show'))
const confirmedOf = (w: World): Rec => routingOf(w).confirmed as Rec
test('T1: a typed skill asks once, with the real id and effort', async (
$, on) => {
const w = asks(['Later'])
await bootRun($, on, FABLE, w)
await typeFeat($)
expect(w.asked).toHaveLength(1)
const first = w.asked[0] as Asked
expect(first.text).toContain('/feat')
expect(first.text).toContain('claude-fable-5-1 at high')
expect(first.options).toEqual(['Later', 'Keep', 'plan', 'implement'])
expect(first.header).toBe('model-router')
})
test('T1: the question follows the model main will really run on', async (
$, on) => {
const w = asks(['Later'])
await bootRun($, on, SONNET, w)
await typeFeat($)
expect(w.asked[0]?.text).toContain('claude-fable-5-1 at high')
})
test('T1: Keep writes the row and its phase, then the skill never asks', async (
$, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
await typeFeat($)
expect(confirmedOf(w)).toEqual({
skills: { feat: 'reflect' },
phases: { reflect: 'reflect' },
})
await typeFeat($)
expect(w.asked).toHaveLength(1)
})
test('T1: Keep leaves the route as it was', async ($, on) => {
await bootRun($, on, FABLE, asks(['Keep']))
await typeFeat($)
expect(await mainNow($)).toContain('skill reflect')
})
test('T1: an alt then Everywhere rewrites the row and routes at once', async (
$, on) => {
const w = asks(['implement', 'Everywhere'])
await bootRun($, on, FABLE, w)
await typeFeat($)
const file = routingOf(w)
expect((file.skills as Rec).feat).toBe('implement')
expect((file.changed as Rec).skills).toEqual({
feat: { from: 'reflect', to: 'implement' },
})
expect(confirmedOf(w).skills).toEqual({ feat: 'implement' })
expect(await mainNow($)).toContain('skill implement')
})
test('T1: the scope question names the two scopes', async ($, on) => {
const w = asks(['implement', 'Later'])
await bootRun($, on, FABLE, w)
await typeFeat($)
expect(w.asked[1]?.options).toEqual(['Everywhere', 'This project only'])
})
test('T1: This project only writes the exception, not the row', async (
$, on) => {
const w = asks(['implement', 'This project only'])
await bootRun($, on, FABLE, w)
await typeFeat($)
const file = routingOf(w)
expect((file.skills as Rec).feat).toBe('reflect')
expect(file.projects).toEqual({ [KEY]: { skills: { feat: 'implement' } } })
expect(file.changed).toEqual({})
expect(confirmedOf(w).skills).toEqual({ feat: 'reflect' })
expect(await mainNow($)).toContain('skill implement')
})
test('T1: after a project exception no other project asks', async ($, on) => {
const w = asks(['implement', 'This project only'])
await bootRun($, on, FABLE, w)
await typeFeat($)
w.repo = { root: '/work/two', remote: 'https://github.com/acme/two.git' }
await $.classic.CwdChanged({ old_cwd: ROOT, new_cwd: '/work/two' })
await typeFeat($)
expect(w.asked).toHaveLength(2)
expect(await mainNow($)).toContain('skill reflect')
})
/** /clear: a new conversation, the config kept and re-read at the prompt. */
function clearOf($: Engine) {
return $.session.end({
reason: 'clear',
sessionId: 's1',
resume: { id: 's1' },
})
}
test('T1: a second change keeps the first shipped phase in changed', async (
$, on) => {
on('session.end', ($, e) => ({ sessionId: e.sessionId }))
const w = asks(['implement', 'Everywhere', 'apply', 'Everywhere'])
await bootRun($, on, FABLE, w)
await typeFeat($)
const file = routingOf(w)
;(file.confirmed as Rec).skills = {}
w.files.set('routing', JSON.stringify(file))
await clearOf($)
await typeFeat($)
expect(((routingOf(w).changed as Rec).skills as Rec).feat).toEqual({
from: 'reflect',
to: 'apply',
})
})
test('T1: a free-text phase counts as an alt', async ($, on) => {
const w = asks(['apply', 'Everywhere'])
await bootRun($, on, FABLE, w)
await typeFeat($)
expect((routingOf(w).skills as Rec).feat).toBe('apply')
})
test('T1: a free-text answer equal to the current phase is a Keep', async (
$, on) => {
const w = asks(['reflect'])
await bootRun($, on, FABLE, w)
await typeFeat($)
expect(confirmedOf(w).skills).toEqual({ feat: 'reflect' })
expect(w.asked).toHaveLength(1)
})
test('T1: Later applies the default, writes nothing, asks no more', async (
$, on) => {
const w = asks(['Later'])
await bootRun($, on, FABLE, w)
await typeFeat($)
await typeFeat($)
expect(w.written).toEqual([])
expect(w.asked).toHaveLength(1)
expect(await mainNow($)).toContain('skill reflect')
})
test('T1: a dismissed dialog is a Later', async ($, on) => {
const w = asks([REJECT])
await bootRun($, on, FABLE, w)
await typeFeat($)
await typeFeat($)
expect(w.written).toEqual([])
expect(w.asked).toHaveLength(1)
expect(await mainNow($)).toContain('skill reflect')
})
test('T1: an unknown phase is a Later and says so', async ($, on) => {
const toasts = recordToasts(on)
const w = asks(['banana'])
await bootRun($, on, FABLE, w)
await typeFeat($)
expect(w.written).toEqual([])
expect(toasts.join('\n')).toContain('unknown phase "banana"')
expect(await mainNow($)).toContain('skill reflect')
})
test('T1: a scope answer that is not a label is a Later', async ($, on) => {
const w = asks(['implement', 'whenever'])
await bootRun($, on, FABLE, w)
await typeFeat($)
expect(w.written).toEqual([])
expect(await mainNow($)).toContain('skill reflect')
})
test('T1: without a repo key only Everywhere is offered', async ($, on) => {
const w = asks(['implement', 'This project only'], { repo: 'fail' })
await bootRun($, on, FABLE, w)
await typeFeat($)
expect(w.asked[1]?.options).toEqual(['Everywhere', 'Later'])
expect(w.written).toEqual([])
})
test('T1: the model\'s own Skill call never asks', async ($, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
await loadSkill($, 'feat')
expect(w.asked).toEqual([])
})
test('T1: a skill without a row never asks', async ($, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
await typed($, '/other')
await skillPrompt($, 'other')
expect(w.asked).toEqual([])
})
test('T1: a project row counts as decided', async ($, on) => {
const w = asks(['Keep'], {
edit: f => { f.projects = { [KEY]: { skills: { feat: 'plan' } } } },
})
await bootRun($, on, FABLE, w)
await typeFeat($)
expect(w.asked).toEqual([])
expect(await mainNow($)).toContain('skill plan')
})
test('T1: a machine override row counts as decided', async ($, on) => {
const w = asks(['Keep'], { home: { skills: { feat: 'plan' } } })
await bootRun($, on, FABLE, w)
await typeFeat($)
expect(w.asked).toEqual([])
})
test('T1: another session\'s decision is seen before the dialog', async (
$, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
const file = routingOf(w)
file.confirmed = { skills: { feat: 'reflect' } }
w.files.set('routing', JSON.stringify(file))
await typeFeat($)
expect(w.asked).toEqual([])
})
test('T1: `ask` turned off elsewhere is seen before the dialog', async (
$, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
const file = routingOf(w)
file.ask = false
w.files.set('routing', JSON.stringify(file))
await typeFeat($)
expect(w.asked).toEqual([])
})
test('T1: a row edited by hand after a Keep asks again', async ($, on) => {
on('session.end', ($, e) => ({ sessionId: e.sessionId }))
const w = asks(['Keep', 'Later'])
await bootRun($, on, FABLE, w)
await typeFeat($)
const file = routingOf(w)
;(file.skills as Rec).feat = 'plan'
w.files.set('routing', JSON.stringify(file))
await clearOf($)
await typeFeat($)
expect(w.asked).toHaveLength(2)
})
test('T1: ask false asks nothing', async ($, on) => {
const w = newWorld({ answers: ['Keep'] })
await bootRun($, on, FABLE, w)
await typeFeat($)
expect(w.asked).toEqual([])
})
test('T1: the router off asks nothing', async ($, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
await route($, 'off')
await typeFeat($)
expect(w.asked).toEqual([])
})
test('T1: routing.json unreadable at the dialog asks nothing', async (
$, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
w.files.delete('routing')
await typeFeat($)
expect(w.asked).toEqual([])
expect(await mainNow($)).toContain('skill reflect')
})
test('T3: routing.json unreadable at the dialog asks nothing', async (
$, on) => {
const w = asks(['Keep'], { routing: 'broken' })
await bootRun($, on, FABLE, w)
await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' })
expect(w.asked).toEqual([])
})
test('T1: a failed write applies the default, key left unasked', async (
$, on) => {
const logs = recordLogs(on)
const w = asks(['Keep', 'Later'])
await bootRun($, on, FABLE, w)
w.failWrite = true
await typeFeat($)
expect(await mainNow($)).toContain('skill reflect')
expect(logs.join('\n')).toContain('first-use dialog failed')
w.failWrite = false
await typeFeat($)
expect(w.asked).toHaveLength(2)
})
const failures = (logs: string[]): string[] =>
logs.filter(l => l.includes('first-use dialog failed'))
test('T1: two failed writes log the failure once', async ($, on) => {
const logs = recordLogs(on)
const w = asks(['Keep', 'Keep'])
await bootRun($, on, FABLE, w)
w.failWrite = true
await typeFeat($)
await typeFeat($)
expect(w.asked).toHaveLength(2)
expect(failures(logs)).toHaveLength(1)
})
test('T1: a rebuild failing after the write leaves the key unasked', async (
$, on) => {
const logs = recordLogs(on)
const w = asks(['implement', 'Everywhere'])
await bootRun($, on, FABLE, w)
w.onWrite = () => { w.files.set(HOME_FILE, '{nope') }
await typeFeat($)
expect((routingOf(w).skills as Rec).feat).toBe('implement')
expect(await mainNow($)).toContain('skill reflect')
const pending = (await route($, 'pending')).split('\n')
expect(pending[1]).toBe('asked this session: none')
expect(failures(logs)).toHaveLength(1)
expect(failures(logs)[0]).toContain('rebuild failed after the write')
})
test('T1: a Keep toasts that routing.json changed', async ($, on) => {
const toasts = recordToasts(on)
await bootRun($, on, FABLE, asks(['Keep']))
await typeFeat($)
expect(toasts.join('\n')).toContain('routing.json updated: commit it')
})
test('writes: only routing.json, 2-space, keys in the documented order', async (
$, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
await typeFeat($)
expect(w.written).toHaveLength(1)
expect(w.written[0]).toMatch(/\/mods\/model-router\/routing\.json$/)
expect(w.files.get('routing')).toMatch(/^\{\n {2}"phases"/)
expect(Object.keys(routingOf(w))).toEqual([
'phases', 'skills', 'agents', 'projects', 'confirmed', 'changed', 'ask',
])
})
test('writes: a missing routing.json is never created', async ($, on) => {
const w = newWorld({ routing: 'absent' })
await bootRun($, on, FABLE, w)
const out = await route($, 'ask off')
expect(out).toContain('missing or unreadable')
expect(w.written).toEqual([])
})
test('writes: two writers on one file both land', async ($, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
const held = deferred()
const seen = deferred()
w.writeGate = held.gate
w.onWrite = seen.release
const first = typeFeat($)
await seen.gate
w.onWrite = undefined
const second = route($, 'ask off')
await new Promise(r => setTimeout(r, 20)) // let a second writer start
held.release()
await first
await second
expect(routingOf(w).ask).toBe(false)
expect(confirmedOf(w).skills).toEqual({ feat: 'reflect' })
})
test('writes: the route tool never writes', async ($, on) => {
const w = newWorld()
await bootRun($, on, FABLE, w)
await $.tool.call({ tool: ROUTE_TOOL, phase: 'plan' })
await $.tool.call({ tool: ROUTE_TOOL, effort: 'low' })
expect(w.written).toEqual([])
})
/** An alt answer, then `scope`: the world after the typed /feat. */
async function changeFeat($: Engine, on: On, scope: string) {
const w = asks(['implement', scope])
await bootRun($, on, FABLE, w)
await typeFeat($)
return w
}
test('writes: an Everywhere change is one write', async ($, on) => {
const w = await changeFeat($, on, 'Everywhere')
expect(w.written).toHaveLength(1)
const file = routingOf(w)
expect((file.skills as Rec).feat).toBe('implement')
expect((file.changed as Rec).skills).toEqual({
feat: { from: 'reflect', to: 'implement' },
})
expect(confirmedOf(w).skills).toEqual({ feat: 'implement' })
})
test('writes: a This project only change is one write', async ($, on) => {
const w = await changeFeat($, on, 'This project only')
expect(w.written).toHaveLength(1)
const file = routingOf(w)
expect(file.projects).toEqual({ [KEY]: { skills: { feat: 'implement' } } })
expect(confirmedOf(w).skills).toEqual({ feat: 'reflect' })
})
/** Rebuilds the fixture in reverse key order: `ask` first, `phases` last. */
function reverseKeys(file: Rec): void {
const copy = { ...file }
for (const key of Object.keys(copy)) delete file[key]
for (const key of Object.keys(copy).reverse()) file[key] = copy[key]
}
test('writes: an out-of-order routing.json is written back in order', async (
$, on) => {
const w = asks(['Keep'], { edit: reverseKeys })
expect(Object.keys(routingOf(w))).toEqual([
'ask', 'changed', 'confirmed', 'projects', 'agents', 'skills', 'phases',
])
await bootRun($, on, FABLE, w)
await typeFeat($)
expect(w.written).toHaveLength(1)
expect(Object.keys(routingOf(w))).toEqual([
'phases', 'skills', 'agents', 'projects', 'confirmed', 'changed', 'ask',
])
})
test('writes: an output over the cap is refused: a Later', async ($, on) => {
const toasts = recordToasts(on)
const pad = (f: Rec) => {
f.pad = 'x'.repeat(65536 - JSON.stringify(f, null, 2).length - 60)
}
const w = asks(['Keep'], { edit: pad })
await bootRun($, on, FABLE, w)
await typeFeat($)
expect(toasts.join('\n')).toContain(
'routing.json would exceed 65536 bytes: nothing saved')
expect(w.written).toEqual([])
})
const NOTHING_SAVED = 'routing.json is missing or unreadable: nothing saved'
test('writes: routing.json gone at the save: a Later, kept asked', async (
$, on) => {
const toasts = recordToasts(on)
const w = asks(['Keep', 'Keep'])
await bootRun($, on, FABLE, w)
const saved = w.files.get('routing') ?? ''
w.onAsk = () => { w.files.delete('routing') }
await typeFeat($)
expect(toasts).toContain(NOTHING_SAVED)
expect(w.written).toEqual([])
expect(w.files.has('routing')).toBe(false)
expect(await mainNow($)).toContain('skill reflect')
w.onAsk = undefined
w.files.set('routing', saved)
await typeFeat($)
expect(w.asked).toHaveLength(1)
const pending = (await route($, 'pending')).split('\n')
expect(pending[1]).toBe('asked this session: skills:feat')
})
// The kit has no disk: an fs.write no hook answers, or whose mock throws
// (the engine skips a failing hook), is refused by the kit's bottom. The
// probe aims at /home/t, where nothing can be created: a real write would
// fail there with a disk error, not this one.
const REFUSED = 'no implementation for fs.write'
/** An inline plugin: `/probe <path>` says what `$.fs.write` did. */
const PROBE: Plugin = {
name: 'fs-probe',
register: on => {
on('command.run', { command: 'probe' }, async ($, e) => {
try {
await $.fs.write(e.args, 'x')
return { text: 'written' }
} catch (err) {
return { text: String(err) }
}
})
},
}
const probe = async ($: Engine): Promise<string> => (await $.command.run({
command: 'probe',
args: `${HOME}/never-written.json`,
origin: { kind: 'composer' },
presentation: { isFullscreen: false, columns: 80 },
})).text ?? ''
test('writes: kit: an fs.write no hook answers is refused', {
plugins: [PROBE],
}, async $ => {
expect(await probe($)).toContain(REFUSED)
})
test('writes: kit: a throwing fs.write mock lands on the same refusal', {
plugins: [PROBE],
}, async ($, on) => {
on('fs.write', () => {
throw new Error('EACCES')
})
expect(await probe($)).toContain(REFUSED)
})
// ---- wave 3-A: T2 agent spawns, T3 route tool ----------------------------
test('T2: a rowed spawn asks with the model and effort it will get', async (
$, on) => {
const w = asks(['Later'])
await bootRun($, on, FABLE, w)
await $.agent.spawn(spawnOf('feater'))
const q = w.asked[0] as Asked
expect(q.text).toContain('First dispatch of feater')
expect(q.text).toContain('claude-sonnet-5-5 at medium')
expect(q.options).toEqual(['Later', 'Keep', 'judge', 'verify'])
})
test('T2: the question carries an explicit effort param', async ($, on) => {
const w = asks(['Later'])
await bootRun($, on, FABLE, w)
await $.tool.call({
tool: 'Agent',
description: 'd',
prompt: 'p',
effort: 'low',
tool_use_id: 't1',
})
await $.agent.spawn(spawnOf('feater'))
expect(w.asked[0]?.text).toContain('claude-sonnet-5-5 at low')
})
test('T2: Keep writes the agent row and the spawn goes on', async ($, on) => {
const w = asks(['Keep'])
const rig = await bootRun($, on, FABLE, w)
await $.agent.spawn(spawnOf('feater'))
expect(confirmedOf(w)).toEqual({
agents: { feater: 'implement' },
phases: { implement: 'implement' },
})
expect(rig.specs).toEqual([SONNET])
await $.agent.spawn(spawnOf('feater'))
expect(w.asked).toHaveLength(1)
})
test('T2: a change routes this very spawn with the new row', async (
$, on) => {
const w = asks(['judge', 'Everywhere'])
const rig = await bootRun($, on, FABLE, w)
await $.agent.spawn(spawnOf('feater'))
expect(rig.specs).toEqual([OPUS])
})
test('T2: an explicit model param never asks', async ($, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
await $.agent.spawn(spawnOf('feater', { model: 'opus' }))
expect(w.asked).toEqual([])
})
test('T2: a spawn inside an agent never asks', async ($, on) => {
const w = asks(['Keep'])
const rig = await bootRun($, on, FABLE, w)
await $.agent.spawn(spawnOf('feater', { parentAgentId: 'a9' }))
expect(w.asked).toEqual([])
expect(rig.specs).toEqual([SONNET])
})
test('T2: a project-defined agent never asks', async ($, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
await offerOf($, 'verifier', 'projectSettings')
await $.agent.spawn(spawnOf('verifier'))
expect(w.asked).toEqual([])
})
test('T2: an agent without a row never asks', async ($, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
await $.agent.spawn(spawnOf('stranger'))
expect(w.asked).toEqual([])
})
test('T2: a parallel spawn of the same agent is routed unasked', async (
$, on) => {
const w = asks(['Keep'])
const rig = await bootRun($, on, FABLE, w)
const held = deferred()
const open = deferred()
w.gate = held.gate
w.onAsk = open.release
const owner = $.agent.spawn(spawnOf('feater'))
await open.gate
await $.agent.spawn(spawnOf('feater'))
expect(rig.specs).toEqual([SONNET])
held.release()
await owner
expect(w.asked).toHaveLength(1)
expect(rig.specs).toEqual([SONNET, SONNET])
})
test('T2: a parallel spawn of another agent is routed unasked', async (
$, on) => {
const w = asks(['Keep'])
const rig = await bootRun($, on, FABLE, w)
const held = deferred()
const open = deferred()
w.gate = held.gate
w.onAsk = open.release
const owner = $.agent.spawn(spawnOf('feater'))
await open.gate
await $.agent.spawn(spawnOf('plan-challenger'))
expect(rig.specs).toEqual([OPUS])
held.release()
await owner
expect(w.asked).toHaveLength(1)
})
test('T2: the unasked agent is asked at its next use', async ($, on) => {
const w = asks(['Keep', 'Keep'])
const rig = await bootRun($, on, FABLE, w)
const held = deferred()
const open = deferred()
w.gate = held.gate
w.onAsk = open.release
const owner = $.agent.spawn(spawnOf('feater'))
await open.gate
await $.agent.spawn(spawnOf('plan-challenger'))
held.release()
await owner
w.gate = undefined
await $.agent.spawn(spawnOf('plan-challenger'))
expect(w.asked).toHaveLength(2)
expect(rig.specs).toEqual([OPUS, SONNET, OPUS])
})
test('T3: a phase declared through the route tool asks Keep or Later', async (
$, on) => {
const w = asks(['Later'])
await bootRun($, on, FABLE, w)
await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' })
const q = w.asked[0] as Asked
expect(q.text).toContain('First use of orchestrate on the main loop')
expect(q.text).toContain('claude-fable-5-1 at medium')
expect(q.options).toEqual(['Later', 'Keep'])
})
test('T3: Keep endorses the phase in routing.json', async ($, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' })
expect(confirmedOf(w)).toEqual({ phases: { orchestrate: 'orchestrate' } })
await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' })
expect(w.asked).toHaveLength(1)
})
test('T3: no change is offered, a phase name answer is a Later', async (
$, on) => {
const w = asks(['plan'])
await bootRun($, on, FABLE, w)
await $.tool.call({ tool: ROUTE_TOOL, phase: 'orchestrate' })
expect(w.written).toEqual([])
expect(await mainNow($)).toContain('model orchestrate')
})
test('T3: a phase endorsed by a skill Keep is not asked again', async (
$, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
await typeFeat($)
await $.tool.call({ tool: ROUTE_TOOL, phase: 'reflect' })
expect(w.asked).toHaveLength(1)
})
test('T3: an effort-only call never asks', async ($, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
await $.tool.call({ tool: ROUTE_TOOL, effort: 'low' })
expect(w.asked).toEqual([])
})
test('T3: a route call inside an agent never asks', async ($, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
await $.tool.call({ tool: ROUTE_TOOL, phase: 'judge', agentId: 'a1' })
expect(w.asked).toEqual([])
})
test('a step never opens a dialog', async ($, on) => {
const w = asks(['Keep'])
const rig = await bootRig($, on, FABLE, 1000, w)
await runStep($, highStep())
expect(w.asked).toEqual([])
expect(rig.seen).toHaveLength(1)
})
// ---- wave 3-A: /route pending, ask, reload -------------------------------
test('/route pending lists what is not confirmed, asked first', async (
$, on) => {
const w = asks(['Later'])
await bootRun($, on, FABLE, w)
await typeFeat($)
const lines = (await route($, 'pending')).split('\n')
expect(lines[0]).toBe('pending confirmation: 19')
expect(lines[1]).toBe('asked this session: skills:feat')
expect(lines[2]).toContain('agents:feater')
expect(lines[2]).toContain('phases:plan')
expect(lines[2]).not.toContain('skills:feat')
})
test('/route pending drops a confirmed row', async ($, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
await typeFeat($)
const text = await route($, 'pending')
expect(text).toContain('pending confirmation: 17')
expect(text).not.toContain('phases:reflect')
})
test('/route ask off writes the flag and silences the dialog', async (
$, on) => {
const w = asks(['Keep'])
await bootRun($, on, FABLE, w)
expect(await route($, 'ask off')).toContain('ask off')
expect(routingOf(w).ask).toBe(false)
await typeFeat($)
expect(w.asked).toEqual([])
})
test('/route ask on turns the dialog back on', async ($, on) => {
const w = newWorld({ answers: ['Later'] })
await bootRun($, on, FABLE, w)
await route($, 'ask on')
expect(routingOf(w).ask).toBe(true)
await typeFeat($)
expect(w.asked).toHaveLength(1)
})
test('/route ask names a machine override that wins', async ($, on) => {
const w = newWorld({ home: { ask: false } })
await bootRun($, on, FABLE, w)
expect(await route($, 'ask on')).toContain('sets ask off and wins')
})
test('/route ask wants on or off', async ($, on) => {
const w = newWorld()
await bootRun($, on, FABLE, w)
expect(await route($, 'ask maybe')).toContain('usage: /route ask on|off')
expect(w.written).toEqual([])
})
test('/route ask refuses a user-less origin like the rest of /route', async (
$, on) => {
const w = newWorld()
await bootRun($, on, FABLE, w)
const out = await $.command.run({
command: 'route',
args: 'ask off',
origin: { kind: 'plugin', name: 'x' },
presentation: { isFullscreen: false, columns: 80 },
})
expect(out.text).toContain('user-only')
expect(w.written).toEqual([])
})
test('/route reload re-reads routing.json and the override', async (
$, on) => {
const w = newWorld()
const rig = await bootRun($, on, FABLE, w)
expect(await featerModel($, rig)).toBe(SONNET)
const file = routingOf(w)
;(file.agents as Rec).feater = 'judge'
w.files.set('routing', JSON.stringify(file))
w.files.set(HOME_FILE, '{"agents":{"verifier":"mechanical"}}')
await route($, 'reload')
expect(await featerModel($, rig)).toBe(OPUS)
await $.agent.spawn(spawnOf('verifier'))
expect(rig.specs[rig.specs.length - 1]).toBe(HAIKU)
})
test('layers: enabled:false in the override survives /clear', async (
$, on) => {
on('session.end', ($, e) => ({ sessionId: e.sessionId }))
const w = newWorld({ home: { enabled: false } })
const rig = await bootRun($, on, FABLE, w)
expect(await route($, 'show')).toContain('router: off (config)')
await clearOf($)
expect(await route($, 'show')).toContain('router: off (config)')
expect(await featerModel($, rig)).toBeUndefined()
})