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.
2544 lines
81 KiB
TypeScript
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()
|
|
})
|