magus-cli 0.0.0-placeholder → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +309 -0
- package/bin/magus.js +170 -0
- package/package.json +67 -3
- package/scripts/build-binaries.ts +113 -0
- package/scripts/capture-builtin.ts +412 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +49 -0
- package/scripts/verify-binary.ts +95 -0
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/alias-adopt.test.ts +365 -0
- package/src/__tests__/alias-parser.test.ts +417 -0
- package/src/__tests__/alias-shell-writer.test.ts +681 -0
- package/src/__tests__/alias-store.test.ts +163 -0
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/catalog-cache-store.test.ts +309 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +288 -0
- package/src/__tests__/cli-live.test.ts +391 -0
- package/src/__tests__/cli-router.test.ts +240 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +445 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +720 -0
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/content-drift.test.ts +353 -0
- package/src/__tests__/conventions-integration.test.ts +830 -0
- package/src/__tests__/conventions-manager.test.ts +1236 -0
- package/src/__tests__/doctor-bins.test.ts +43 -0
- package/src/__tests__/doctor.test.ts +463 -0
- package/src/__tests__/dotenv.test.ts +195 -0
- package/src/__tests__/enabled-not-installed.test.ts +94 -0
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/git-worktree.test.ts +108 -0
- package/src/__tests__/github-budget.test.ts +206 -0
- package/src/__tests__/gitignore-check.test.ts +71 -0
- package/src/__tests__/gitignore-detector.test.ts +176 -0
- package/src/__tests__/gitignore-fixer.test.ts +317 -0
- package/src/__tests__/gitignore-resolver.test.ts +193 -0
- package/src/__tests__/gitignore-service.test.ts +137 -0
- package/src/__tests__/go-module.test.ts +43 -0
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +104 -0
- package/src/__tests__/launcher-signal.test.ts +308 -0
- package/src/__tests__/local-marketplace-scan.test.ts +174 -0
- package/src/__tests__/manifest.test.ts +281 -0
- package/src/__tests__/markdown-renderer.test.ts +142 -0
- package/src/__tests__/marketplace-badge.test.ts +140 -0
- package/src/__tests__/marketplace-refresh.test.ts +335 -0
- package/src/__tests__/marketplace-version.test.ts +84 -0
- package/src/__tests__/marketplaces.test.ts +128 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/mcp-registry.test.ts +158 -0
- package/src/__tests__/model-visuals.test.tsx +2466 -0
- package/src/__tests__/models-adapter.test.ts +332 -0
- package/src/__tests__/models-cli.test.ts +273 -0
- package/src/__tests__/models-core.test.ts +802 -0
- package/src/__tests__/models-manager.test.ts +500 -0
- package/src/__tests__/models-presets-marketplace.test.ts +168 -0
- package/src/__tests__/models-screen-state.test.ts +315 -0
- package/src/__tests__/moved-marketplace.test.ts +86 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/owned-marketplaces.test.ts +93 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
- package/src/__tests__/plugin-releases.test.ts +98 -0
- package/src/__tests__/plugin-requires.test.ts +187 -0
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- package/src/__tests__/plugin-setup.test.ts +1016 -0
- package/src/__tests__/profile-adopt.test.ts +177 -0
- package/src/__tests__/profile-command.test.ts +110 -0
- package/src/__tests__/profile-materializer.test.ts +238 -0
- package/src/__tests__/profile-sync.test.ts +154 -0
- package/src/__tests__/registry-version-resolution.test.ts +88 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +351 -0
- package/src/__tests__/scope-action.test.ts +134 -0
- package/src/__tests__/scope-squares.test.tsx +164 -0
- package/src/__tests__/selection-scope.test.ts +100 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/skill-install-files.test.ts +136 -0
- package/src/__tests__/skills-catalog-client.test.ts +48 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1098 -0
- package/src/__tests__/styles-origins.test.ts +414 -0
- package/src/__tests__/styles-screen-state.test.ts +457 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/symlink-manager.test.ts +180 -0
- package/src/__tests__/tabbar-layout.test.ts +100 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
- package/src/__tests__/theme-env.test.ts +281 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/toolchain.test.ts +232 -0
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +637 -0
- package/src/__tests__/update-plan.test.ts +1373 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/__tests__/useGitignoreModal.test.ts +79 -0
- package/src/__tests__/version-check-refresh.test.ts +45 -0
- package/src/__tests__/version-snapshot.test.ts +212 -0
- package/src/__tests__/version-unknown-state.test.ts +388 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +520 -0
- package/src/cli/bootstrap.ts +134 -0
- package/src/cli/doctor.ts +218 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/install.ts +382 -0
- package/src/cli/live.ts +374 -0
- package/src/cli/models.ts +297 -0
- package/src/cli/profile.ts +197 -0
- package/src/cli/prompt.ts +32 -0
- package/src/cli/router.ts +207 -0
- package/src/cli/update-view.ts +526 -0
- package/src/cli/update.ts +613 -0
- package/src/cli/upgrade.ts +97 -0
- package/src/data/alias-flags.ts +301 -0
- package/src/data/cli-tools.ts +146 -0
- package/src/data/community-styles.ts +531 -0
- package/src/data/gitignore-defaults.ts +33 -0
- package/src/data/gitignore-reasons.ts +93 -0
- package/src/data/gitignore-templates.ts +25 -0
- package/src/data/marketplaces.ts +278 -0
- package/src/data/mcp-servers.ts +51 -0
- package/src/data/models-presets.ts +329 -0
- package/src/data/predefined-profiles.ts +252 -0
- package/src/data/settings-catalog.ts +714 -0
- package/src/data/skill-repos.ts +91 -0
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/main.tsx +110 -0
- package/src/markdown.d.ts +13 -0
- package/src/opentui.d.ts +203 -0
- package/src/services/active-closure.ts +117 -0
- package/src/services/alias-settings.ts +60 -0
- package/src/services/alias-shell-writer.ts +1244 -0
- package/src/services/alias-store.ts +172 -0
- package/src/services/binary-signing.ts +78 -0
- package/src/services/catalog-cache-store.ts +316 -0
- package/src/services/claude-cli.ts +304 -0
- package/src/services/claude-runner.ts +31 -0
- package/src/services/claude-settings.ts +1589 -0
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/content-drift.ts +208 -0
- package/src/services/conventions-manager.ts +945 -0
- package/src/services/doctor-bins.ts +63 -0
- package/src/services/doctor.ts +570 -0
- package/src/services/dotenv.ts +181 -0
- package/src/services/git-worktree.ts +129 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/gitignore-check.ts +63 -0
- package/src/services/gitignore-detector.ts +167 -0
- package/src/services/gitignore-fixer.ts +280 -0
- package/src/services/gitignore-resolver.ts +208 -0
- package/src/services/gitignore-service.ts +167 -0
- package/src/services/hook-registration.ts +218 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/local-marketplace.ts +587 -0
- package/src/services/manifest.ts +250 -0
- package/src/services/marketplace-catalog-git.ts +173 -0
- package/src/services/marketplace-catalog.ts +129 -0
- package/src/services/marketplace-fetcher.ts +400 -0
- package/src/services/marketplace-refresh.ts +253 -0
- package/src/services/marketplace-sync.ts +145 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/mcp-registry.ts +171 -0
- package/src/services/models-core.ts +969 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +878 -0
- package/src/services/plugin-mcp-config.ts +242 -0
- package/src/services/plugin-releases.ts +47 -0
- package/src/services/plugin-requires.ts +227 -0
- package/src/services/plugin-setup.ts +965 -0
- package/src/services/profile-adopt.ts +206 -0
- package/src/services/profile-materializer.ts +133 -0
- package/src/services/profile-sync.ts +158 -0
- package/src/services/profiles.ts +225 -0
- package/src/services/resolver.ts +344 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/settings-manager.ts +307 -0
- package/src/services/skills-manager.ts +1111 -0
- package/src/services/skillsmp-client.ts +193 -0
- package/src/services/styles-manager.ts +1294 -0
- package/src/services/symlink-manager.ts +189 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +431 -0
- package/src/services/update-engine.ts +691 -0
- package/src/services/update-plan.ts +863 -0
- package/src/services/version-check.ts +127 -0
- package/src/services/version-snapshot.ts +189 -0
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +27 -0
- package/src/types/gitignore.ts +68 -0
- package/src/types/index.ts +447 -0
- package/src/ui/App.tsx +538 -0
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/modelsAdapter.ts +213 -0
- package/src/ui/adapters/pluginsAdapter.ts +413 -0
- package/src/ui/adapters/settingsAdapter.ts +168 -0
- package/src/ui/adapters/skillsAdapter.ts +229 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/CategoryHeader.tsx +64 -0
- package/src/ui/components/EmptyFilterState.tsx +32 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +63 -0
- package/src/ui/components/ScrollableList.tsx +95 -0
- package/src/ui/components/SearchInput.tsx +57 -0
- package/src/ui/components/StyledText.tsx +75 -0
- package/src/ui/components/TabBar.tsx +101 -0
- package/src/ui/components/layout/FooterHints.tsx +78 -0
- package/src/ui/components/layout/Panel.tsx +63 -0
- package/src/ui/components/layout/ProgressBar.tsx +44 -0
- package/src/ui/components/layout/ScopeTabs.tsx +54 -0
- package/src/ui/components/layout/ScreenLayout.tsx +321 -0
- package/src/ui/components/layout/index.ts +9 -0
- package/src/ui/components/modals/ConfirmModal.tsx +62 -0
- package/src/ui/components/modals/InputModal.tsx +75 -0
- package/src/ui/components/modals/LoadingModal.tsx +51 -0
- package/src/ui/components/modals/MessageModal.tsx +66 -0
- package/src/ui/components/modals/ModalContainer.tsx +159 -0
- package/src/ui/components/modals/SelectModal.tsx +90 -0
- package/src/ui/components/modals/index.ts +6 -0
- package/src/ui/components/primitives/ActionHints.tsx +44 -0
- package/src/ui/components/primitives/DetailSection.tsx +23 -0
- package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
- package/src/ui/components/primitives/MetaText.tsx +40 -0
- package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
- package/src/ui/components/primitives/SelectableRow.tsx +30 -0
- package/src/ui/components/primitives/index.ts +9 -0
- package/src/ui/hooks/index.ts +2 -0
- package/src/ui/hooks/useGitignoreModal.ts +95 -0
- package/src/ui/hooks/useKeyboard.ts +24 -0
- package/src/ui/hooks/useKeyboardHandler.ts +63 -0
- package/src/ui/registry.ts +33 -0
- package/src/ui/renderers/cliToolRenderers.tsx +326 -0
- package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/mcpRenderers.tsx +149 -0
- package/src/ui/renderers/modelRenderers.tsx +1309 -0
- package/src/ui/renderers/modelVisuals.tsx +1402 -0
- package/src/ui/renderers/pluginRenderers.tsx +1089 -0
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/renderers/profileRenderers.tsx +406 -0
- package/src/ui/renderers/settingsRenderers.tsx +218 -0
- package/src/ui/renderers/skillRenderers.tsx +479 -0
- package/src/ui/renderers/styleRenderers.tsx +813 -0
- package/src/ui/screens/AliasScreen.tsx +1558 -0
- package/src/ui/screens/CliToolsScreen.tsx +367 -0
- package/src/ui/screens/EnvVarsScreen.tsx +221 -0
- package/src/ui/screens/GitignoreScreen.tsx +562 -0
- package/src/ui/screens/McpRegistryScreen.tsx +366 -0
- package/src/ui/screens/McpScreen.tsx +256 -0
- package/src/ui/screens/ModelsScreen.tsx +542 -0
- package/src/ui/screens/PluginsScreen.tsx +1464 -0
- package/src/ui/screens/ProfilesScreen.tsx +438 -0
- package/src/ui/screens/SkillsScreen.tsx +853 -0
- package/src/ui/screens/StylesScreen.tsx +1064 -0
- package/src/ui/screens/index.ts +11 -0
- package/src/ui/state/AppContext.tsx +233 -0
- package/src/ui/state/DimensionsContext.tsx +154 -0
- package/src/ui/state/reducer.ts +810 -0
- package/src/ui/state/types.ts +503 -0
- package/src/ui/theme-mode.ts +352 -0
- package/src/ui/theme-resolve.ts +147 -0
- package/src/ui/theme.ts +175 -0
- package/src/utils/clipboard.ts +58 -0
- package/src/utils/command-utils.ts +144 -0
- package/src/utils/config-dir.ts +45 -0
- package/src/utils/file-locking.ts +146 -0
- package/src/utils/fuzzy-search.ts +138 -0
- package/src/utils/go-module.ts +36 -0
- package/src/utils/open-file.ts +84 -0
- package/src/utils/run.ts +67 -0
- package/src/utils/string-utils.ts +88 -0
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract tests for the pure routing core.
|
|
3
|
+
*
|
|
4
|
+
* The behaviour asserted here is not invented: each rule cites the run in
|
|
5
|
+
* `benches/agent-model-routing/` (AMR-1) that measured it on real Claude Code, interactive,
|
|
6
|
+
* n=5. A test that drifts from those numbers is a bug in the code or a signal to re-run the
|
|
7
|
+
* bench — never a reason to relax the assertion.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
BUILT_IN_PRESETS,
|
|
14
|
+
DEFAULT_AGENT_GRADES,
|
|
15
|
+
findPreset,
|
|
16
|
+
presetNames,
|
|
17
|
+
} from "../data/models-presets.js";
|
|
18
|
+
import {
|
|
19
|
+
EFFORTS,
|
|
20
|
+
type Effort,
|
|
21
|
+
GRADES,
|
|
22
|
+
MATES,
|
|
23
|
+
type ModelsConfig,
|
|
24
|
+
baseAlias,
|
|
25
|
+
buildSettingsPatch,
|
|
26
|
+
computeModelsStatus,
|
|
27
|
+
evaluateAgentHook,
|
|
28
|
+
isMate,
|
|
29
|
+
isRoutableModel,
|
|
30
|
+
resolveAgentRouting,
|
|
31
|
+
validateModelsConfig,
|
|
32
|
+
} from "../services/models-core.js";
|
|
33
|
+
|
|
34
|
+
const base = (): ModelsConfig => ({
|
|
35
|
+
version: 1,
|
|
36
|
+
preset: "test",
|
|
37
|
+
main: { model: "opus", effort: "medium" },
|
|
38
|
+
grades: {
|
|
39
|
+
smart: { model: "fable", effort: "xhigh" },
|
|
40
|
+
normal: { model: "opus", effort: "medium" },
|
|
41
|
+
cheap: { model: "sonnet", effort: "low" },
|
|
42
|
+
},
|
|
43
|
+
agents: {
|
|
44
|
+
"dev:architect": "smart",
|
|
45
|
+
Explore: "cheap",
|
|
46
|
+
"dev:researcher": { model: "inherit" },
|
|
47
|
+
},
|
|
48
|
+
fallback: "normal",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const noFullId = () => null;
|
|
52
|
+
|
|
53
|
+
describe("validateModelsConfig", () => {
|
|
54
|
+
test("accepts a well-formed config", () => {
|
|
55
|
+
expect(validateModelsConfig(base())).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("every built-in preset validates", () => {
|
|
59
|
+
for (const p of BUILT_IN_PRESETS) {
|
|
60
|
+
expect({ preset: p.preset, errors: validateModelsConfig(p) }).toEqual({
|
|
61
|
+
preset: p.preset,
|
|
62
|
+
errors: [],
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("rejects a full model id, and says why", () => {
|
|
68
|
+
const c = base();
|
|
69
|
+
(c.main as { model: string }).model = "claude-opus-5";
|
|
70
|
+
const errors = validateModelsConfig(c);
|
|
71
|
+
expect(errors).toHaveLength(1);
|
|
72
|
+
expect(errors[0]?.path).toBe("main.model");
|
|
73
|
+
expect(errors[0]?.message).toContain("go stale");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("rejects a non-alias on a grade — AMR-1 bogus-model: it refuses the spawn outright", () => {
|
|
77
|
+
const c = base();
|
|
78
|
+
(c.grades.cheap as { model: string }).model = "kimi-k2-thinking";
|
|
79
|
+
const errors = validateModelsConfig(c);
|
|
80
|
+
expect(errors.map((e) => e.path)).toEqual(["grades.cheap.model"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("allows [1m] on main but not on a grade", () => {
|
|
84
|
+
const ok = base();
|
|
85
|
+
(ok.main as { model: string }).model = "opus[1m]";
|
|
86
|
+
expect(validateModelsConfig(ok)).toEqual([]);
|
|
87
|
+
|
|
88
|
+
const bad = base();
|
|
89
|
+
(bad.grades.smart as { model: string }).model = "opus[1m]";
|
|
90
|
+
expect(validateModelsConfig(bad).map((e) => e.path)).toEqual([
|
|
91
|
+
"grades.smart.model",
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("rejects `inherit` on main — the main thread needs a real model", () => {
|
|
96
|
+
const c = base();
|
|
97
|
+
(c.main as { model: string }).model = "inherit";
|
|
98
|
+
expect(validateModelsConfig(c)[0]?.path).toBe("main.model");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("rejects two efforts for one model, naming both sites", () => {
|
|
102
|
+
// modelSettings is keyed by MODEL, so one of the two would silently lose.
|
|
103
|
+
const c = base();
|
|
104
|
+
c.grades.normal = { model: "opus", effort: "xhigh" }; // main already claims opus/medium
|
|
105
|
+
const errors = validateModelsConfig(c);
|
|
106
|
+
expect(errors).toHaveLength(1);
|
|
107
|
+
expect(errors[0]?.path).toBe("grades.normal");
|
|
108
|
+
expect(errors[0]?.message).toContain("main");
|
|
109
|
+
expect(errors[0]?.message).toContain("medium");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("the same model with the SAME effort twice is fine", () => {
|
|
113
|
+
const c = base();
|
|
114
|
+
c.grades.normal = { model: "opus", effort: "medium" };
|
|
115
|
+
expect(validateModelsConfig(c)).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("rejects effort on `inherit`, which names no model to key it by", () => {
|
|
119
|
+
const c = base();
|
|
120
|
+
c.agents["dev:researcher"] = { model: "inherit", effort: "low" } as never;
|
|
121
|
+
expect(validateModelsConfig(c).map((e) => e.path)).toEqual([
|
|
122
|
+
"agents.dev:researcher.effort",
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("rejects an unknown grade name in agents", () => {
|
|
127
|
+
const c = base();
|
|
128
|
+
c.agents.whatever = "turbo" as never;
|
|
129
|
+
expect(validateModelsConfig(c).map((e) => e.path)).toEqual([
|
|
130
|
+
"agents.whatever",
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("a wrong version stops immediately rather than emitting noise", () => {
|
|
135
|
+
const errors = validateModelsConfig({ version: 2, main: {}, grades: {} });
|
|
136
|
+
expect(errors).toHaveLength(1);
|
|
137
|
+
expect(errors[0]?.path).toBe("version");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a non-object is one clear error", () => {
|
|
141
|
+
expect(validateModelsConfig("nope")).toEqual([
|
|
142
|
+
{ path: "", message: "must be a JSON object" },
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("resolveAgentRouting", () => {
|
|
148
|
+
test("an explicit agent grade wins", () => {
|
|
149
|
+
expect(resolveAgentRouting(base(), "dev:architect")).toEqual({
|
|
150
|
+
model: "fable",
|
|
151
|
+
effort: "xhigh",
|
|
152
|
+
source: "agent-grade",
|
|
153
|
+
grade: "smart",
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a per-agent override beats the grade table", () => {
|
|
158
|
+
expect(resolveAgentRouting(base(), "dev:researcher")).toEqual({
|
|
159
|
+
model: "inherit",
|
|
160
|
+
source: "agent-override",
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("an unknown agent falls to the fallback grade, so a new plugin routes on day one", () => {
|
|
165
|
+
expect(resolveAgentRouting(base(), "brand-new:agent")).toEqual({
|
|
166
|
+
model: "opus",
|
|
167
|
+
effort: "medium",
|
|
168
|
+
source: "default-grade",
|
|
169
|
+
grade: "normal",
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("a missing subagent_type still resolves", () => {
|
|
174
|
+
expect(resolveAgentRouting(base(), undefined).source).toBe("default-grade");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("evaluateAgentHook", () => {
|
|
179
|
+
const agentCall = (input: Record<string, unknown> = {}) => ({
|
|
180
|
+
tool_name: "Agent",
|
|
181
|
+
tool_input: { subagent_type: "dev:architect", prompt: "go", ...input },
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("rewrites the model and carries the whole input through", () => {
|
|
185
|
+
const d = evaluateAgentHook(agentCall(), base());
|
|
186
|
+
expect(d.kind).toBe("update");
|
|
187
|
+
if (d.kind !== "update") return;
|
|
188
|
+
expect(d.output.hookSpecificOutput.updatedInput).toEqual({
|
|
189
|
+
subagent_type: "dev:architect",
|
|
190
|
+
prompt: "go",
|
|
191
|
+
model: "fable",
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("ALWAYS emits hookEventName — AMR-1 hook-minimal fired 5/5 and was ignored without it", () => {
|
|
196
|
+
const d = evaluateAgentHook(agentCall(), base());
|
|
197
|
+
if (d.kind !== "update") throw new Error("expected an update");
|
|
198
|
+
expect(d.output.hookSpecificOutput.hookEventName).toBe("PreToolUse");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("never emits permissionDecision — measured neither required nor harmful, so it only widens permission", () => {
|
|
202
|
+
const d = evaluateAgentHook(agentCall(), base());
|
|
203
|
+
if (d.kind !== "update") throw new Error("expected an update");
|
|
204
|
+
expect(Object.keys(d.output.hookSpecificOutput).sort()).toEqual([
|
|
205
|
+
"hookEventName",
|
|
206
|
+
"updatedInput",
|
|
207
|
+
]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("passes through a tool that is not Agent", () => {
|
|
211
|
+
expect(
|
|
212
|
+
evaluateAgentHook({ tool_name: "Bash", tool_input: {} }, base()),
|
|
213
|
+
).toEqual({
|
|
214
|
+
kind: "passthrough",
|
|
215
|
+
reason: "not-agent",
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("passes through with no config — fail open", () => {
|
|
220
|
+
expect(evaluateAgentHook(agentCall(), null)).toEqual({
|
|
221
|
+
kind: "passthrough",
|
|
222
|
+
reason: "no-config",
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("respects a model the caller already chose", () => {
|
|
227
|
+
expect(evaluateAgentHook(agentCall({ model: "haiku" }), base())).toEqual({
|
|
228
|
+
kind: "passthrough",
|
|
229
|
+
reason: "explicit-model",
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("`inherit` is the opt-out and touches nothing", () => {
|
|
234
|
+
expect(
|
|
235
|
+
evaluateAgentHook(agentCall({ subagent_type: "dev:researcher" }), base()),
|
|
236
|
+
).toEqual({
|
|
237
|
+
kind: "passthrough",
|
|
238
|
+
reason: "inherit",
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("refuses to inject an unroutable value even if a hand-edited config carries one", () => {
|
|
243
|
+
// AMR-1 bogus-model: injecting a non-alias refuses the spawn entirely, so passing
|
|
244
|
+
// through costs the routing while injecting costs the user every subagent.
|
|
245
|
+
const c = base();
|
|
246
|
+
(c.grades.smart as { model: string }).model = "kimi-k2-thinking";
|
|
247
|
+
expect(evaluateAgentHook(agentCall(), c)).toEqual({
|
|
248
|
+
kind: "passthrough",
|
|
249
|
+
reason: "unroutable-model",
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("an empty model string is not treated as a caller's choice", () => {
|
|
254
|
+
const d = evaluateAgentHook(agentCall({ model: "" }), base());
|
|
255
|
+
expect(d.kind).toBe("update");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A mate passes through, and says so in its own words.
|
|
260
|
+
*
|
|
261
|
+
* The reason is not cosmetic. `unroutable-model` is the hook reporting a BROKEN config —
|
|
262
|
+
* a value that would refuse the spawn if it were injected — and a mate is the config
|
|
263
|
+
* working exactly as written. Reporting one as the other would make every deliberate
|
|
264
|
+
* external routing look like a typo in the hook log, which is the only place a user can
|
|
265
|
+
* see what the hook decided.
|
|
266
|
+
*/
|
|
267
|
+
test("a mate passes through as `mate`, never as `unroutable-model`", () => {
|
|
268
|
+
for (const mate of MATES) {
|
|
269
|
+
const c = base();
|
|
270
|
+
(c.grades.smart as { model: string; effort?: Effort }).model = mate;
|
|
271
|
+
// biome-ignore lint/performance/noDelete: a mate carries no effort
|
|
272
|
+
delete (c.grades.smart as { effort?: Effort }).effort;
|
|
273
|
+
const decision = evaluateAgentHook(agentCall(), c);
|
|
274
|
+
expect(decision).toEqual({ kind: "passthrough", reason: "mate" });
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("a mate pinned to one agent passes through, and the rest still route", () => {
|
|
279
|
+
const c = base();
|
|
280
|
+
c.agents["dev:docs"] = { model: "kangaroo" } as never;
|
|
281
|
+
expect(
|
|
282
|
+
evaluateAgentHook(agentCall({ subagent_type: "dev:docs" }), c),
|
|
283
|
+
).toEqual({ kind: "passthrough", reason: "mate" });
|
|
284
|
+
// The negative control: the mate changed nothing about the agents around it.
|
|
285
|
+
expect(evaluateAgentHook(agentCall(), c).kind).toBe("update");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("a mate is never written into updatedInput — there is no update to write it into", () => {
|
|
289
|
+
const c = base();
|
|
290
|
+
c.agents["dev:docs"] = { model: "mate1" } as never;
|
|
291
|
+
const decision = evaluateAgentHook(
|
|
292
|
+
agentCall({ subagent_type: "dev:docs" }),
|
|
293
|
+
c,
|
|
294
|
+
);
|
|
295
|
+
// Stated as the absence of an `update`, because `updatedInput.model` is the exact
|
|
296
|
+
// field AMR-1 measured as spawn-refusing for a non-alias.
|
|
297
|
+
expect(decision.kind).not.toBe("update");
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("buildSettingsPatch", () => {
|
|
302
|
+
test("writes main model and effort", () => {
|
|
303
|
+
const { set } = buildSettingsPatch(base(), noFullId);
|
|
304
|
+
expect(set.model).toBe("opus");
|
|
305
|
+
expect(set.effortLevel).toBe("medium");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("writes BOTH key forms with the same value — AMR-1 effort-conflict: the full id wins", () => {
|
|
309
|
+
const resolve = (a: string) =>
|
|
310
|
+
a === "fable" ? "claude-fable-5-1" : `claude-${a}-5`;
|
|
311
|
+
const { set } = buildSettingsPatch(base(), resolve as never);
|
|
312
|
+
expect(set.modelSettings).toEqual({
|
|
313
|
+
opus: { effortLevel: "medium" },
|
|
314
|
+
"claude-opus-5": { effortLevel: "medium" },
|
|
315
|
+
fable: { effortLevel: "xhigh" },
|
|
316
|
+
"claude-fable-5-1": { effortLevel: "xhigh" },
|
|
317
|
+
sonnet: { effortLevel: "low" },
|
|
318
|
+
"claude-sonnet-5": { effortLevel: "low" },
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("a resolver that cannot answer leaves the alias key alone rather than inventing an id", () => {
|
|
323
|
+
const { set } = buildSettingsPatch(base(), noFullId);
|
|
324
|
+
expect(set.modelSettings).toEqual({
|
|
325
|
+
opus: { effortLevel: "medium" },
|
|
326
|
+
fable: { effortLevel: "xhigh" },
|
|
327
|
+
sonnet: { effortLevel: "low" },
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("omits modelSettings entirely when no effort is declared", () => {
|
|
332
|
+
const c = base();
|
|
333
|
+
c.main = { model: "opus" };
|
|
334
|
+
c.grades = {
|
|
335
|
+
smart: { model: "fable" },
|
|
336
|
+
normal: { model: "opus" },
|
|
337
|
+
cheap: { model: "sonnet" },
|
|
338
|
+
};
|
|
339
|
+
c.agents = {};
|
|
340
|
+
const { set } = buildSettingsPatch(c, noFullId);
|
|
341
|
+
expect(set.modelSettings).toBeUndefined();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* A mate may never reach `modelSettings`, and the guard that stops it is load-bearing.
|
|
346
|
+
*
|
|
347
|
+
* `add()` returns early on anything that is not an alias, which is currently the ONLY
|
|
348
|
+
* thing keeping `mate1` out of settings — and it reads as an accident of ordering rather
|
|
349
|
+
* than a rule, so it is pinned here. Two ways it could go wrong, both tested:
|
|
350
|
+
*
|
|
351
|
+
* - a `mate1` key, which Claude Code can key nothing by;
|
|
352
|
+
* - an `undefined` key, which is what a `resolveFullId` returning null would write if
|
|
353
|
+
* the guard moved and the full-id branch ran on a name it cannot resolve.
|
|
354
|
+
*
|
|
355
|
+
* The config is built by hand past the validator on purpose: the validator refuses effort
|
|
356
|
+
* on a mate, and this function is also reached from `doctor` and the TUI with whatever a
|
|
357
|
+
* hand-edited file holds.
|
|
358
|
+
*/
|
|
359
|
+
test("a mate produces no modelSettings key of its own, and no `undefined` key", () => {
|
|
360
|
+
const c = base();
|
|
361
|
+
(c.grades.smart as { model: string; effort?: Effort }).model = "mate1";
|
|
362
|
+
c.agents.pinned = { model: "kangaroo" } as never;
|
|
363
|
+
|
|
364
|
+
const withResolver = buildSettingsPatch(
|
|
365
|
+
c,
|
|
366
|
+
((alias: string) => `claude-${alias}-5`) as never,
|
|
367
|
+
);
|
|
368
|
+
const keys = Object.keys(
|
|
369
|
+
withResolver.set.modelSettings as Record<string, unknown>,
|
|
370
|
+
);
|
|
371
|
+
for (const mate of ["mate1", "mate2", "kangaroo"]) {
|
|
372
|
+
expect(keys).not.toContain(mate);
|
|
373
|
+
expect(keys).not.toContain(`claude-${mate}-5`);
|
|
374
|
+
}
|
|
375
|
+
expect(keys).not.toContain("undefined");
|
|
376
|
+
// And the aliases beside it are untouched — the mate cost nothing else its entry.
|
|
377
|
+
expect(keys).toContain("opus");
|
|
378
|
+
|
|
379
|
+
// The same, with an effort the validator would have refused. This is the branch that
|
|
380
|
+
// actually exercises the `isModelAlias` gate rather than the `effort === undefined`
|
|
381
|
+
// one above it.
|
|
382
|
+
const forced = base();
|
|
383
|
+
forced.grades.smart = { model: "mate1", effort: "xhigh" } as never;
|
|
384
|
+
const patch = buildSettingsPatch(forced, noFullId);
|
|
385
|
+
const forcedKeys = Object.keys(
|
|
386
|
+
patch.set.modelSettings as Record<string, unknown>,
|
|
387
|
+
);
|
|
388
|
+
expect(forcedKeys).not.toContain("mate1");
|
|
389
|
+
expect(forcedKeys).not.toContain("undefined");
|
|
390
|
+
expect(forcedKeys.sort()).toEqual(["opus", "sonnet"]);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("a [1m] main model keys its effort by the base alias", () => {
|
|
394
|
+
const c = base();
|
|
395
|
+
(c.main as { model: string }).model = "opus[1m]";
|
|
396
|
+
const { set } = buildSettingsPatch(c, noFullId);
|
|
397
|
+
expect(set.model).toBe("opus[1m]");
|
|
398
|
+
expect((set.modelSettings as Record<string, unknown>).opus).toEqual({
|
|
399
|
+
effortLevel: "medium",
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
describe("computeModelsStatus", () => {
|
|
405
|
+
const applied = () => buildSettingsPatch(base(), noFullId).set;
|
|
406
|
+
|
|
407
|
+
test("off when there is no config", () => {
|
|
408
|
+
expect(
|
|
409
|
+
computeModelsStatus({
|
|
410
|
+
config: null,
|
|
411
|
+
errors: [],
|
|
412
|
+
hookRegistered: true,
|
|
413
|
+
settings: {},
|
|
414
|
+
resolveFullId: noFullId,
|
|
415
|
+
}).state,
|
|
416
|
+
).toBe("off");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("invalid reports the validation errors as drift", () => {
|
|
420
|
+
const s = computeModelsStatus({
|
|
421
|
+
config: null,
|
|
422
|
+
errors: [{ path: "main.model", message: "bad" }],
|
|
423
|
+
hookRegistered: true,
|
|
424
|
+
settings: {},
|
|
425
|
+
resolveFullId: noFullId,
|
|
426
|
+
});
|
|
427
|
+
expect(s.state).toBe("invalid");
|
|
428
|
+
expect(s.drift[0]).toContain("main.model");
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("unhooked outranks stale — nothing routes, so that is the headline", () => {
|
|
432
|
+
const s = computeModelsStatus({
|
|
433
|
+
config: base(),
|
|
434
|
+
errors: [],
|
|
435
|
+
hookRegistered: false,
|
|
436
|
+
settings: {},
|
|
437
|
+
resolveFullId: noFullId,
|
|
438
|
+
});
|
|
439
|
+
expect(s.state).toBe("unhooked");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("applied when settings already match", () => {
|
|
443
|
+
const s = computeModelsStatus({
|
|
444
|
+
config: base(),
|
|
445
|
+
errors: [],
|
|
446
|
+
hookRegistered: true,
|
|
447
|
+
settings: applied(),
|
|
448
|
+
resolveFullId: noFullId,
|
|
449
|
+
});
|
|
450
|
+
expect(s.state).toBe("on");
|
|
451
|
+
expect(s.drift).toEqual([]);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("settings differing from the config is CUSTOM, not a fault", () => {
|
|
455
|
+
// There is no state for it and no drift list. A project may run models and efforts no
|
|
456
|
+
// shipped preset names; the list says so with a `Custom` row carrying those settings,
|
|
457
|
+
// rather than the header reporting a difference as something to fix.
|
|
458
|
+
const s = computeModelsStatus({
|
|
459
|
+
config: base(),
|
|
460
|
+
errors: [],
|
|
461
|
+
hookRegistered: true,
|
|
462
|
+
settings: { ...applied(), effortLevel: "high" },
|
|
463
|
+
resolveFullId: noFullId,
|
|
464
|
+
});
|
|
465
|
+
expect(s.state).toBe("on");
|
|
466
|
+
expect(s.drift).toEqual([]);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("settings written by nobody at all is still not a fault", () => {
|
|
470
|
+
// The strongest form of the same claim, and the one the old `1 drift` badge described
|
|
471
|
+
// most misleadingly: every key absent counted as ONE message.
|
|
472
|
+
const s = computeModelsStatus({
|
|
473
|
+
config: base(),
|
|
474
|
+
errors: [],
|
|
475
|
+
hookRegistered: true,
|
|
476
|
+
settings: {},
|
|
477
|
+
resolveFullId: noFullId,
|
|
478
|
+
});
|
|
479
|
+
expect(s.state).toBe("on");
|
|
480
|
+
expect(s.drift).toEqual([]);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("drift survives for the states that ARE faults", () => {
|
|
484
|
+
// The negative control for the three tests above: `drift` was not emptied everywhere,
|
|
485
|
+
// only where it described a difference. An unhooked config routes NOTHING, and that
|
|
486
|
+
// line is the reason why.
|
|
487
|
+
const s = computeModelsStatus({
|
|
488
|
+
config: base(),
|
|
489
|
+
errors: [],
|
|
490
|
+
hookRegistered: false,
|
|
491
|
+
settings: applied(),
|
|
492
|
+
resolveFullId: noFullId,
|
|
493
|
+
});
|
|
494
|
+
expect(s.state).toBe("unhooked");
|
|
495
|
+
expect(s.drift.length).toBeGreaterThan(0);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("warns that CLAUDE_CODE_SUBAGENT_MODEL is now inert", () => {
|
|
499
|
+
const s = computeModelsStatus({
|
|
500
|
+
config: base(),
|
|
501
|
+
errors: [],
|
|
502
|
+
hookRegistered: true,
|
|
503
|
+
settings: applied(),
|
|
504
|
+
resolveFullId: noFullId,
|
|
505
|
+
subagentModelEnv: "claude-haiku-4-5",
|
|
506
|
+
});
|
|
507
|
+
expect(s.warnings.join(" ")).toContain("CLAUDE_CODE_SUBAGENT_MODEL");
|
|
508
|
+
expect(s.state).toBe("on");
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
describe("presets", () => {
|
|
513
|
+
/**
|
|
514
|
+
* The first request shape still ships verbatim: fable on the orchestrator at `medium`.
|
|
515
|
+
*
|
|
516
|
+
* The second — "fable main, opus for other tasks, sonnet for quick tasks" — does NOT,
|
|
517
|
+
* because the revised preset spec took opus out of `fable-lead` entirely and made it
|
|
518
|
+
* fable in every seat but `cheap`. That is a deliberate change to what the preset means,
|
|
519
|
+
* not a regression, and it is asserted here so the removal stays visible rather than
|
|
520
|
+
* being discovered later as a surprise.
|
|
521
|
+
*/
|
|
522
|
+
test("fable-lead puts fable on the orchestrator at medium", () => {
|
|
523
|
+
const fable = findPreset("fable-lead");
|
|
524
|
+
expect(fable?.main).toEqual({ model: "fable", effort: "medium" });
|
|
525
|
+
// opus is gone from this preset; the only non-fable seat is `cheap`.
|
|
526
|
+
expect(
|
|
527
|
+
GRADES.map((grade) => baseAlias(fable?.grades[grade].model ?? "")),
|
|
528
|
+
).toEqual(["fable", "fable", "sonnet"]);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The orchestrator never gets the most spend.
|
|
533
|
+
*
|
|
534
|
+
* This replaced a test that ranked the models against each other — `haiku < sonnet <
|
|
535
|
+
* opus < fable` — and asserted `smart` held the top-ranked one. That is a CAPABILITY
|
|
536
|
+
* claim, it was written from memory, and it went stale the moment the presets started
|
|
537
|
+
* treating fable as the fast model rather than the strongest. Effort is the axis this
|
|
538
|
+
* config actually controls, so the principle is stated in effort: the thread that
|
|
539
|
+
* dispatches the work is never strictly the biggest spend in the preset.
|
|
540
|
+
*
|
|
541
|
+
* A tie is allowed. `sonnet-economy` runs `main` and `smart` both at `xhigh`, because
|
|
542
|
+
* its saving is the MODEL rather than a throttled effort on top of one.
|
|
543
|
+
*/
|
|
544
|
+
test("no preset spends more on the orchestrator than on the work it dispatches", () => {
|
|
545
|
+
const level = (effort: string | undefined) =>
|
|
546
|
+
effort ? EFFORTS.indexOf(effort as Effort) : -1;
|
|
547
|
+
for (const p of BUILT_IN_PRESETS) {
|
|
548
|
+
const highestTier = Math.max(
|
|
549
|
+
...Object.values(p.grades).map((g) => level(g.effort)),
|
|
550
|
+
);
|
|
551
|
+
expect({
|
|
552
|
+
preset: p.preset,
|
|
553
|
+
mainIsNotTheBiggestSpend: level(p.main.effort) <= highestTier,
|
|
554
|
+
}).toEqual({ preset: p.preset, mainIsNotTheBiggestSpend: true });
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* One model, one effort — the only cross-seat rule there is.
|
|
560
|
+
*
|
|
561
|
+
* `modelSettings` is keyed by model, so a preset naming one model in two seats at two
|
|
562
|
+
* efforts cannot be written to settings at all: one value silently wins. This replaced a
|
|
563
|
+
* test that fixed each model's effort across EVERY preset (`fable` always `medium`, and
|
|
564
|
+
* so on). That was a convention invented to satisfy this constraint, not the constraint
|
|
565
|
+
* itself, and it blocked legitimate presets — `fable-lead` and `fable-top` differ by
|
|
566
|
+
* nothing except fable's effort, which is the whole point of having both.
|
|
567
|
+
*
|
|
568
|
+
* Presets never collide with each other; only one is applied to a project at a time.
|
|
569
|
+
*/
|
|
570
|
+
test("within one preset, a model appears at exactly one effort", () => {
|
|
571
|
+
for (const p of BUILT_IN_PRESETS) {
|
|
572
|
+
const efforts = new Map<string, Set<string>>();
|
|
573
|
+
const seats = [
|
|
574
|
+
{ seat: "main", model: p.main.model, effort: p.main.effort },
|
|
575
|
+
...GRADES.map((grade) => ({
|
|
576
|
+
seat: grade,
|
|
577
|
+
model: p.grades[grade].model,
|
|
578
|
+
effort: p.grades[grade].effort,
|
|
579
|
+
})),
|
|
580
|
+
];
|
|
581
|
+
for (const { model, effort } of seats) {
|
|
582
|
+
const alias = baseAlias(model);
|
|
583
|
+
if (alias === "inherit" || effort === undefined) continue;
|
|
584
|
+
const seen = efforts.get(alias) ?? new Set<string>();
|
|
585
|
+
seen.add(effort);
|
|
586
|
+
efforts.set(alias, seen);
|
|
587
|
+
}
|
|
588
|
+
for (const [alias, seen] of efforts) {
|
|
589
|
+
expect({ preset: p.preset, model: alias, efforts: [...seen] }).toEqual({
|
|
590
|
+
preset: p.preset,
|
|
591
|
+
model: alias,
|
|
592
|
+
efforts: [...seen].slice(0, 1),
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("no preset uses haiku", () => {
|
|
599
|
+
// Excluded deliberately, not by oversight — asserted so re-adding it is a decision.
|
|
600
|
+
for (const p of BUILT_IN_PRESETS) {
|
|
601
|
+
const models = [
|
|
602
|
+
p.main.model,
|
|
603
|
+
...GRADES.map((grade) => p.grades[grade].model),
|
|
604
|
+
].map(baseAlias);
|
|
605
|
+
expect({ preset: p.preset, usesHaiku: models.includes("haiku") }).toEqual(
|
|
606
|
+
{
|
|
607
|
+
preset: p.preset,
|
|
608
|
+
usesHaiku: false,
|
|
609
|
+
},
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("preset names are unique", () => {
|
|
615
|
+
expect(new Set(presetNames()).size).toBe(presetNames().length);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("the shared agent table only names real grades", () => {
|
|
619
|
+
for (const [agent, grade] of Object.entries(DEFAULT_AGENT_GRADES)) {
|
|
620
|
+
expect({
|
|
621
|
+
agent,
|
|
622
|
+
ok: ["smart", "normal", "cheap"].includes(grade),
|
|
623
|
+
}).toEqual({ agent, ok: true });
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
describe("isRoutableModel", () => {
|
|
629
|
+
test("accepts the four aliases and nothing else", () => {
|
|
630
|
+
for (const a of ["opus", "sonnet", "haiku", "fable"])
|
|
631
|
+
expect(isRoutableModel(a)).toBe(true);
|
|
632
|
+
for (const bad of [
|
|
633
|
+
"inherit",
|
|
634
|
+
"claude-opus-5",
|
|
635
|
+
"opus[1m]",
|
|
636
|
+
"",
|
|
637
|
+
"kimi-k2-thinking",
|
|
638
|
+
null,
|
|
639
|
+
5,
|
|
640
|
+
])
|
|
641
|
+
expect(isRoutableModel(bad)).toBe(false);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* THE constraint the whole mate feature is built around.
|
|
646
|
+
*
|
|
647
|
+
* AMR-1 `bogus-model` measured 5/5 that a non-alias `model` refuses the spawn outright —
|
|
648
|
+
* the session gets a tool error and no subagent at all. A mate is a non-alias by
|
|
649
|
+
* construction, so a change that made this pass would not route agents to claudish, it
|
|
650
|
+
* would stop the agents spawning. There is no version of this that is a nice improvement.
|
|
651
|
+
*/
|
|
652
|
+
test("a mate is NEVER routable — injecting one would refuse the spawn", () => {
|
|
653
|
+
for (const mate of MATES) expect(isRoutableModel(mate)).toBe(false);
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
describe("mates", () => {
|
|
658
|
+
test("isMate is exact — no prefix rule, no near match", () => {
|
|
659
|
+
for (const mate of MATES) expect(isMate(mate)).toBe(true);
|
|
660
|
+
// `mate10` is a typo, not a tenth slot; `Mate1` is a different string. Resolving
|
|
661
|
+
// either into a real slot would route an agent somewhere nobody named.
|
|
662
|
+
for (const bad of [
|
|
663
|
+
"mate",
|
|
664
|
+
"mate10",
|
|
665
|
+
"Mate1",
|
|
666
|
+
"mate3",
|
|
667
|
+
"kangaroo ",
|
|
668
|
+
"",
|
|
669
|
+
null,
|
|
670
|
+
3,
|
|
671
|
+
])
|
|
672
|
+
expect(isMate(bad)).toBe(false);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("`kangaroo` is mate3 under another name, so all three are one vocabulary", () => {
|
|
676
|
+
expect([...MATES]).toEqual(["mate1", "mate2", "kangaroo"]);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("a grade may name a mate, with no effort", () => {
|
|
680
|
+
const c = base();
|
|
681
|
+
(c.grades.smart as { model: string; effort?: Effort }).model = "mate1";
|
|
682
|
+
// biome-ignore lint/performance/noDelete: absence is the point — a mate carries none
|
|
683
|
+
delete (c.grades.smart as { effort?: Effort }).effort;
|
|
684
|
+
expect(validateModelsConfig(c)).toEqual([]);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test("an agent may be pinned to a mate", () => {
|
|
688
|
+
const c = base();
|
|
689
|
+
c.agents["dev:researcher"] = { model: "kangaroo" } as never;
|
|
690
|
+
expect(validateModelsConfig(c)).toEqual([]);
|
|
691
|
+
expect(resolveAgentRouting(c, "dev:researcher")).toEqual({
|
|
692
|
+
model: "kangaroo",
|
|
693
|
+
source: "agent-override",
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test("main REJECTS a mate, and the message says why the main thread cannot take one", () => {
|
|
698
|
+
const c = base();
|
|
699
|
+
(c.main as { model: string }).model = "mate1";
|
|
700
|
+
const errors = validateModelsConfig(c);
|
|
701
|
+
expect(errors.map((e) => e.path)).toEqual(["main.model"]);
|
|
702
|
+
// Not a bare "must be one of …": the reason is that magus writes this value into
|
|
703
|
+
// settings.model, which has to name a model Claude Code itself runs.
|
|
704
|
+
expect(errors[0]?.message).toContain("settings.model");
|
|
705
|
+
expect(errors[0]?.message).toContain("claudish");
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test("effort on a mate is ALLOWED — the rule reversed", () => {
|
|
709
|
+
// It used to be an error, on the reasoning that effort is keyed by model and a mate
|
|
710
|
+
// owns no `modelSettings` key. The key part is still true; the conclusion was wrong. A
|
|
711
|
+
// mate's effort is a per-call value handed to claudish, not a settings key, so there
|
|
712
|
+
// is nothing for it to collide with and no reason to refuse it.
|
|
713
|
+
//
|
|
714
|
+
// What must remain true is that it never reaches `modelSettings` — pinned separately,
|
|
715
|
+
// on `buildSettingsPatch`, because that is where the damage would be.
|
|
716
|
+
const onGrade = base();
|
|
717
|
+
onGrade.grades.cheap = { model: "mate2", effort: "high" } as never;
|
|
718
|
+
expect(validateModelsConfig(onGrade)).toEqual([]);
|
|
719
|
+
|
|
720
|
+
const onAgent = base();
|
|
721
|
+
onAgent.agents["dev:docs"] = { model: "mate1", effort: "low" } as never;
|
|
722
|
+
expect(validateModelsConfig(onAgent)).toEqual([]);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("effort on `inherit` is STILL an error — the two questions are not the same one", () => {
|
|
726
|
+
// The half of the old rule that survives. `inherit` names no model at all, so there is
|
|
727
|
+
// no key AND no call to attach a value to; a mate names one claudish resolves.
|
|
728
|
+
const onGrade = base();
|
|
729
|
+
onGrade.grades.cheap = { model: "inherit", effort: "high" } as never;
|
|
730
|
+
const gradeErrors = validateModelsConfig(onGrade);
|
|
731
|
+
expect(gradeErrors.map((e) => e.path)).toEqual(["grades.cheap.effort"]);
|
|
732
|
+
expect(gradeErrors[0]?.message).toContain("inherit names no model");
|
|
733
|
+
|
|
734
|
+
const onAgent = base();
|
|
735
|
+
onAgent.agents["dev:docs"] = { model: "inherit", effort: "low" } as never;
|
|
736
|
+
expect(validateModelsConfig(onAgent).map((e) => e.path)).toEqual([
|
|
737
|
+
"agents.dev:docs.effort",
|
|
738
|
+
]);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test("two mates on the same model may declare different efforts", () => {
|
|
742
|
+
// Not a conflict, for the same reason the one-model-one-effort pass skips mates
|
|
743
|
+
// entirely: neither owns a `modelSettings` key, so neither can lose one to the other.
|
|
744
|
+
const c = base();
|
|
745
|
+
c.agents.a = { model: "mate1", effort: "low" } as never;
|
|
746
|
+
c.agents.b = { model: "mate2", effort: "xhigh" } as never;
|
|
747
|
+
c.mates = {
|
|
748
|
+
mate1: { model: "grok-4.6" },
|
|
749
|
+
mate2: { model: "grok-4.6" },
|
|
750
|
+
} as never;
|
|
751
|
+
expect(validateModelsConfig(c)).toEqual([]);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Two seats on one mate is not the `modelSettings` conflict two seats on one alias is.
|
|
756
|
+
*
|
|
757
|
+
* The one-model-one-effort pass exists because `modelSettings` is keyed by model and one
|
|
758
|
+
* key cannot hold two efforts. A mate owns no key at all, so there is nothing to collide
|
|
759
|
+
* — and sending several agents to the same external model is the ordinary way to use a
|
|
760
|
+
* slot, not a mistake to report.
|
|
761
|
+
*/
|
|
762
|
+
test("several agents may share one mate", () => {
|
|
763
|
+
const c = base();
|
|
764
|
+
c.agents.a = { model: "mate1" } as never;
|
|
765
|
+
c.agents.b = { model: "mate1" } as never;
|
|
766
|
+
(c.grades.cheap as { model: string; effort?: Effort }).model = "mate1";
|
|
767
|
+
// biome-ignore lint/performance/noDelete: absence is the point — a mate carries none
|
|
768
|
+
delete (c.grades.cheap as { effort?: Effort }).effort;
|
|
769
|
+
expect(validateModelsConfig(c)).toEqual([]);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Validity may not depend on the machine reading the file.
|
|
774
|
+
*
|
|
775
|
+
* `models.json` is committed and shared through `profiles.json`, so if `mate1` were only
|
|
776
|
+
* valid where `multimodel@magus` happens to be installed, the same file would be valid on
|
|
777
|
+
* one teammate's laptop and invalid on the next one's — and the error would land on
|
|
778
|
+
* whoever had not installed the plugin, over a routing decision they did not make.
|
|
779
|
+
*
|
|
780
|
+
* Pinned at the strongest available level: the module imports NOTHING, so there is no
|
|
781
|
+
* seam through which a machine fact could reach the validator. Whether the slots are
|
|
782
|
+
* DRAWN is a separate question, answered in the renderers from a boolean passed in.
|
|
783
|
+
*/
|
|
784
|
+
test("the core imports nothing, so validity cannot depend on the machine", () => {
|
|
785
|
+
// Comments are stripped first, the way hook-import-policy.test.ts does it: this file
|
|
786
|
+
// explains itself in English, and a sentence containing `from "…"` is not an import.
|
|
787
|
+
const specifiersOf = (file: string): string[] => {
|
|
788
|
+
const source = readFileSync(
|
|
789
|
+
path.join(import.meta.dir, "..", "services", file),
|
|
790
|
+
"utf-8",
|
|
791
|
+
)
|
|
792
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
793
|
+
.replace(/\/\/[^\n]*/g, "");
|
|
794
|
+
return [...source.matchAll(/\bfrom\s+["']([^"']+)["']/g)].map(
|
|
795
|
+
(match) => match[1] as string,
|
|
796
|
+
);
|
|
797
|
+
};
|
|
798
|
+
expect(specifiersOf("models-core.ts")).toEqual([]);
|
|
799
|
+
// Negative control: a regex that matched nothing would pass that forever.
|
|
800
|
+
expect(specifiersOf("models-manager.ts").length).toBeGreaterThan(0);
|
|
801
|
+
});
|
|
802
|
+
});
|