diff --git a/docs/agents/agent-skills.mdx b/docs/agents/agent-skills.mdx index 0f91e2eb30..09e5bbf5f0 100644 --- a/docs/agents/agent-skills.mdx +++ b/docs/agents/agent-skills.mdx @@ -240,6 +240,45 @@ Substitution rules: Use the `argument-hint` frontmatter field to document the expected arguments in invocation UIs. +## Per-skill model routing + +Mechanical skills (session wrap-up, worktree helpers, PR chores) rarely need your frontier model. Skill invocations can be routed to a **model class** — an indirection that survives model churn, since bindings name a class and only the class map names concrete models. + +Configure the three canonical classes — `large`, `medium`, `small` — in **Settings → Models → Model Classes** (a model plus an optional thinking level per class). Canonical names keep skill bindings portable across machines. The classes are stored in `~/.mux/config.json`, where values use the [one-shot override syntax](/config/models#one-shot-overrides): a model alias or full `provider:model` id, with an optional `+thinking` suffix (named level or model-relative numeric index). Hand-edited custom class names in config.json also work and are preserved by the Settings editor: + +```json +{ + "modelClasses": { + "large": "fable+max", + "medium": "sonnet+high", + "small": "haiku+0" + } +} +``` + +Bind skills to classes in either of two places: + +- **Skill frontmatter** — the spec-standard `metadata` map, so the binding travels with the skill and other agent tools ignore it: + + ```yaml + metadata: + model-class: small + ``` + +- **Config routing table** — for skills you don't own, `skillModelClasses` in `~/.mux/config.json` maps skill names to classes and **wins over frontmatter**: + + ```json + { + "skillModelClasses": { "done": "small", "wt": "small" } + } + ``` + +Routing applies to the slash invocation's send only: the workspace's selected model is untouched, and your next message streams on it again. If auto-compaction triggers, the threshold is computed against the routed model's context window, while the compaction request itself keeps your model (it must fit the uncompacted history). + +Broken bindings fail loudly: when a bound class exists but its value is malformed, or no configured provider route can serve its model (a retired model, a removed provider or key), the send fails with an error naming the mapping to fix — and the Model Classes editor shows the same "no configured route" warning inline. A dangling `skillModelClasses` table entry (naming a class you deleted) also errors, since the table is your own explicit routing intent. Frontmatter bindings to a class you never defined are simply ignored, so skills you don't own can ship `model-class` metadata without ever breaking your sends; infrastructure hiccups (an unreadable skill or config) likewise fall back to the workspace model instead of failing the send. + +To override routing for one invocation, compose a one-shot prefix with the skill: `/sonnet+high /done` runs the skill on Sonnet regardless of its class. A model-carrying one-shot always wins over class routing; a thinking-only one-shot (`/+2 /done`) layers on top of it — the skill still routes to its class model, at the overridden thinking level. Numeric thinking indices are model-relative and resolve against the model that actually streams: in `/+0 /done`, the `0` means the class model's lowest allowed level, not the workspace model's. Both overrides survive compact-and-retry: the rebuilt send keeps the one-shot's model and thinking instead of falling back to routing or ambient settings. + ## Dynamic context injection (experiment) Enable the **Skill dynamic context injection** experiment (Settings → Experiments) to let skills pull live command output into their instructions. When you invoke a skill, any line whose entire content is `` !`command` `` runs in the workspace, and the line is replaced with a fenced block containing the command’s output before the model sees the skill: diff --git a/docs/config/models.mdx b/docs/config/models.mdx index f0bdd2dd2f..80ff29e1b7 100644 --- a/docs/config/models.mdx +++ b/docs/config/models.mdx @@ -65,12 +65,15 @@ Override the model or thinking level for a single message using slash commands. ### Syntax -| Command | Effect | -| --------------------------- | ---------------------------------------- | -| `/sonnet explain this code` | Use Sonnet for one message | -| `/opus+high deep review` | Use Opus with high thinking | -| `/haiku+0 quick answer` | Use Haiku at its lowest thinking level | -| `/+2 analyze this` | Keep current model, set thinking level 2 | +| Command | Effect | +| --------------------------- | ------------------------------------------- | +| `/sonnet explain this code` | Use Sonnet for one message | +| `/opus+high deep review` | Use Opus with high thinking | +| `/haiku+0 quick answer` | Use Haiku at its lowest thinking level | +| `/+2 analyze this` | Keep current model, set thinking level 2 | +| `/haiku+0 /done` | Run the `done` skill on Haiku for this send | + +One-shot prefixes compose with [skill invocations](/agents/agent-skills): `/haiku+0 /done cleanup` invokes the skill normally (arguments, snapshots) while overriding the model for that send. An explicit one-shot also wins over the skill's own [model-class routing](/agents/agent-skills#per-skill-model-routing). ### Thinking levels diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 6073cf69ec..3c3fbbd0e6 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -2542,6 +2542,9 @@ const ChatInputInner: React.FC = (props) => { agentSkillDescriptors, api, discovery: skillDiscovery, + // One-shot × skill composition ("/haiku+0 /done") ships for workspace + // sends; the creation composer has no one-shot support to compose with. + composeOneShot: variant === "workspace", }); const combinedSkillRefs = await resolveInlineSkillRefsForSend({ messageText, @@ -2662,6 +2665,9 @@ const ChatInputInner: React.FC = (props) => { try { const modelOneShot = parsed?.type === "model-oneshot" ? parsed : null; + // Model/thinking override from either a bare one-shot ("/haiku+0 msg") + // or one composed with a skill invocation ("/haiku+0 /done args"). + const oneShotOverride = modelOneShot ?? skillInvocation?.oneShot ?? null; // Mirror the creation-composer /goal bypass: with attachments present, // send the raw text as a normal message instead of processing the // command, which would drop the files. Transferred staging-failure @@ -2684,7 +2690,7 @@ const ChatInputInner: React.FC = (props) => { // the composer, it must not restore stale command text over the newer turn. asyncCommandTokenRef.current++; - const modelOverride = modelOneShot?.modelString; + const modelOverride = oneShotOverride?.modelString; // Regular message (or / one-shot override) - send directly via API const messageTextForSend = modelOneShot?.message ?? skillInvocation?.userText ?? messageText; @@ -2719,23 +2725,38 @@ const ChatInputInner: React.FC = (props) => { } } + // Composed one-shot sends highlight the full "/haiku+0 /done" prefix: + // the transcript badge check requires rawCommand.startsWith(commandPrefix), + // and the combined prefix also keeps the explicit override visible. + const composedPrefixMatch = skillInvocation?.oneShot + ? new RegExp(`^\\S+\\s+/${skillInvocation.descriptor.name}(?=\\s|$)`).exec( + messageText.trim() + ) + : null; const skillMuxMetadata = skillInvocation ? buildSkillInvocationMetadata( appendStagedAttachmentNotice(messageText, sendAttachments), skillInvocation.descriptor, - skillInvocation.argumentText + skillInvocation.argumentText, + composedPrefixMatch?.[0] ) : undefined; const policyModel = modelOverride ?? baseModel; // Preflight: if the message includes PDFs, ensure the selected model can accept them. + // Routable skill invocations (no explicit model override) may stream on a + // class model with different PDF capabilities than the workspace model, so + // this local check would judge the wrong model both ways — defer to the + // backend gate, which validates against the routed model and rejects with + // a persisted, visible error. + const pdfPreflightModelIsAuthoritative = !(skillInvocation && !modelOverride); const pdfAttachments = attachments.filter( (attachment): attachment is Extract => attachment.kind === "provider" && getBaseMediaType(attachment.mediaType) === PDF_MEDIA_TYPE ); - if (pdfAttachments.length > 0) { + if (pdfAttachments.length > 0 && pdfPreflightModelIsAuthoritative) { const caps = getModelCapabilitiesResolved(policyModel, providersConfig); if (caps && !caps.supportsPdfInput) { const pdfCapableKnownModels = Object.values(KNOWN_MODELS) @@ -2902,7 +2923,7 @@ const ChatInputInner: React.FC = (props) => { // One-shot models/thinking shouldn't update the persisted session defaults. // Resolve thinking level: numeric indices are model-relative (0 = model's lowest allowed level) - const rawThinkingOverride = modelOneShot?.thinkingLevel; + const rawThinkingOverride = oneShotOverride?.thinkingLevel; const thinkingOverride = rawThinkingOverride != null ? resolveThinkingInput(rawThinkingOverride, policyModel) @@ -2919,7 +2940,17 @@ const ChatInputInner: React.FC = (props) => { : {}), ...(modelOverride ? { model: modelOverride } : {}), ...(thinkingOverride ? { thinkingLevel: thinkingOverride } : {}), - ...(modelOneShot ? { skipAiSettingsPersistence: true } : {}), + ...(oneShotOverride ? { skipAiSettingsPersistence: true } : {}), + // Only a model-carrying one-shot bypasses class routing; a + // thinking-only override (/+2 /skill) layers on top of routing. + ...(modelOverride ? { skipSkillModelRouting: true } : {}), + // Numeric thinking is model-relative and thinkingOverride above was + // resolved against the workspace model. A routable skill send may + // stream on a different (class) model, so pass the raw index for the + // backend to re-resolve against whatever model actually streams. + ...(skillInvocation && !modelOverride && typeof rawThinkingOverride === "number" + ? { oneShotThinkingIndex: rawThinkingOverride } + : {}), ...(goalInterventionPolicy ? { goalInterventionPolicy } : {}), ...(overrides?.queueDispatchMode ? { queueDispatchMode: overrides.queueDispatchMode } @@ -2958,17 +2989,23 @@ const ChatInputInner: React.FC = (props) => { setDraft(preSendDraft); setDraftReviews(preSendReviews); } else { - // Track telemetry for successful message send + // Track telemetry for successful message send. Skill class routing + // can swap the model and thinking backend-side; the send result + // reports both so usage is attributed to what actually streams + // (queued sends report none and fall back to the requested values). telemetry.messageSent( props.workspaceId, - effectiveModel, + result.data?.routedModel ?? effectiveModel, sendMessageOptions.agentId ?? agentId ?? WORKSPACE_DEFAULTS.agentId, finalMessageText.length, runtimeType, - sendMessageOptions.thinkingLevel ?? "off" + // Fall back to what this send actually carried (sendOptions + // includes a composed one-shot's thinking), not the ambient + // workspace setting. + result.data?.routedThinkingLevel ?? sendOptions.thinkingLevel ?? "off" ); - if (modelOneShot) { + if (oneShotOverride) { trackCommandUsed("model"); } diff --git a/src/browser/features/ChatInput/utils.oneShotSkillComposition.test.ts b/src/browser/features/ChatInput/utils.oneShotSkillComposition.test.ts new file mode 100644 index 0000000000..527b56b067 --- /dev/null +++ b/src/browser/features/ChatInput/utils.oneShotSkillComposition.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test"; + +import { KNOWN_MODELS } from "@/common/constants/knownModels"; +import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; +import { parseCommandWithSkillInvocation } from "./utils"; + +function descriptor(name: string): AgentSkillDescriptor { + return { name, description: `${name} description`, scope: "project" }; +} + +describe("parseCommandWithSkillInvocation one-shot composition", () => { + test("composes '/haiku+0 /done args' into a skill invocation with a one-shot override", async () => { + const result = await parseCommandWithSkillInvocation({ + messageText: "/haiku+0 /done now please", + agentSkillDescriptors: [descriptor("done")], + api: null, + discovery: null, + composeOneShot: true, + }); + + expect(result.parsed).toBeNull(); + expect(result.skillInvocation?.descriptor.name).toBe("done"); + expect(result.skillInvocation?.userText).toBe("Using skill done: now please"); + // Arguments are relative to the skill token so $ARGUMENTS substitution + // sees "now please", not the one-shot prefix. + expect(result.skillInvocation?.argumentText).toBe("now please"); + expect(result.skillInvocation?.oneShot).toEqual({ + modelString: KNOWN_MODELS.HAIKU.id, + thinkingLevel: 0, + }); + }); + + test("composes a thinking-only override ('/+2 /done')", async () => { + const result = await parseCommandWithSkillInvocation({ + messageText: "/+2 /done", + agentSkillDescriptors: [descriptor("done")], + api: null, + discovery: null, + composeOneShot: true, + }); + + expect(result.parsed).toBeNull(); + expect(result.skillInvocation?.userText).toBe("Use skill done"); + expect(result.skillInvocation?.oneShot).toEqual({ thinkingLevel: 2 }); + }); + + test("does not compose without the composeOneShot opt-in (creation composer)", async () => { + const result = await parseCommandWithSkillInvocation({ + messageText: "/haiku+0 /done now", + agentSkillDescriptors: [descriptor("done")], + api: null, + discovery: null, + }); + + expect(result.skillInvocation).toBeNull(); + expect(result.parsed?.type).toBe("model-oneshot"); + }); + + test("keeps valid registered-command invocations out of composition ('/haiku+0 /compact')", async () => { + const result = await parseCommandWithSkillInvocation({ + messageText: "/haiku+0 /compact", + agentSkillDescriptors: [descriptor("compact")], + api: null, + discovery: null, + composeOneShot: true, + }); + + expect(result.skillInvocation).toBeNull(); + expect(result.parsed).toMatchObject({ type: "model-oneshot", message: "/compact" }); + }); + + test("mirrors direct invocation for unknown-command remainders, even on command-colliding names", async () => { + // "/compact now" is an invalid compact usage: its handler returns + // unknown-command, and unknown commands are exactly what skill invocation + // consumes. Direct typing already resolves a skill named "compact" here, + // so the composed form must behave identically. + const direct = await parseCommandWithSkillInvocation({ + messageText: "/compact now", + agentSkillDescriptors: [descriptor("compact")], + api: null, + discovery: null, + composeOneShot: true, + }); + const composed = await parseCommandWithSkillInvocation({ + messageText: "/haiku+0 /compact now", + agentSkillDescriptors: [descriptor("compact")], + api: null, + discovery: null, + composeOneShot: true, + }); + + expect(direct.skillInvocation?.descriptor.name).toBe("compact"); + expect(composed.skillInvocation?.descriptor.name).toBe("compact"); + expect(composed.skillInvocation?.oneShot).toEqual({ + modelString: KNOWN_MODELS.HAIKU.id, + thinkingLevel: 0, + }); + }); + + test("keeps nested one-shots out of composition ('/haiku+0 /opus hi')", async () => { + const result = await parseCommandWithSkillInvocation({ + messageText: "/haiku+0 /opus hi", + agentSkillDescriptors: [descriptor("done")], + api: null, + discovery: null, + composeOneShot: true, + }); + + expect(result.skillInvocation).toBeNull(); + expect(result.parsed).toMatchObject({ type: "model-oneshot", message: "/opus hi" }); + }); + + test("falls back to a plain one-shot when the remainder is not a known skill", async () => { + const result = await parseCommandWithSkillInvocation({ + messageText: "/haiku+0 /nothere do it", + agentSkillDescriptors: [descriptor("done")], + api: null, + discovery: null, + composeOneShot: true, + }); + + expect(result.skillInvocation).toBeNull(); + expect(result.parsed).toMatchObject({ type: "model-oneshot", message: "/nothere do it" }); + }); + + test("plain skill invocations are unaffected by the composition flag", async () => { + const result = await parseCommandWithSkillInvocation({ + messageText: "/done now", + agentSkillDescriptors: [descriptor("done")], + api: null, + discovery: null, + composeOneShot: true, + }); + + expect(result.parsed).toBeNull(); + expect(result.skillInvocation?.descriptor.name).toBe("done"); + expect(result.skillInvocation?.oneShot).toBeUndefined(); + }); +}); diff --git a/src/browser/features/ChatInput/utils.ts b/src/browser/features/ChatInput/utils.ts index 385da03aa1..9881fd5ef0 100644 --- a/src/browser/features/ChatInput/utils.ts +++ b/src/browser/features/ChatInput/utils.ts @@ -8,6 +8,7 @@ import { import { resolveSkillUserInvocable } from "@/common/orpc/schemas/agentSkill"; import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; import type { ParsedRuntime } from "@/common/types/runtime"; +import type { ParsedThinkingInput } from "@/common/types/thinking"; import { buildAgentSkillMetadata, dedupeAgentSkillRefs, @@ -29,6 +30,16 @@ export interface SkillInvocation { userText: string; /** Trimmed text after the slash command (e.g. "123 high" for "/fix-issue 123 high"). */ argumentText: string; + /** + * One-shot model/thinking override composed with the invocation + * ("/haiku+0 /done args"). Applies to this send only; carrying + * skipAiSettingsPersistence also bypasses backend per-skill class routing + * (an explicit override wins over the skill's model class). + */ + oneShot?: { + modelString?: string; + thinkingLevel?: ParsedThinkingInput; + }; } export type SkillResolutionTarget = @@ -45,11 +56,17 @@ function isUnknownSlashCommand(value: ParsedCommand): value is UnknownSlashComma export function buildSkillInvocationMetadata( rawCommand: string, descriptor: AgentSkillDescriptor, - argumentText: string + argumentText: string, + /** + * Overrides the default `/${name}` prefix for composed one-shot invocations + * ("/haiku+0 /done"): the transcript badge only renders when rawCommand + * starts with commandPrefix, so the prefix must include the one-shot token. + */ + commandPrefixOverride?: string ): MuxMessageMetadata { return buildAgentSkillMetadata({ rawCommand, - commandPrefix: `/${descriptor.name}`, + commandPrefix: commandPrefixOverride ?? `/${descriptor.name}`, skillName: descriptor.name, scope: descriptor.scope, arguments: argumentText, @@ -133,9 +150,11 @@ export async function parseCommandWithSkillInvocation(options: { agentSkillDescriptors: AgentSkillDescriptor[]; api: APIClient | null; discovery: SkillResolutionTarget | null; + /** Allow "/model+thinking /skill args" composition (workspace sends only). */ + composeOneShot?: boolean; }): Promise<{ parsed: ParsedCommand; skillInvocation: SkillInvocation | null }> { const parsed = parseCommand(options.messageText); - const skillInvocation = await resolveSkillInvocation({ + let skillInvocation = await resolveSkillInvocation({ messageText: options.messageText, parsed, agentSkillDescriptors: options.agentSkillDescriptors, @@ -143,6 +162,35 @@ export async function parseCommandWithSkillInvocation(options: { discovery: options.discovery, }); + // Compose one-shot model overrides with skill invocations: "/haiku+0 /done args" + // runs the done skill on Haiku for this send only. Re-running parseCommand on the + // one-shot's message keeps registered commands ("/haiku+0 /compact") and nested + // one-shots out of skill resolution — only unknown-command remainders are + // candidate skills, exactly like a bare "/done args". + if ( + options.composeOneShot === true && + skillInvocation == null && + parsed?.type === "model-oneshot" + ) { + const innerParsed = parseCommand(parsed.message); + const innerInvocation = await resolveSkillInvocation({ + messageText: parsed.message, + parsed: innerParsed, + agentSkillDescriptors: options.agentSkillDescriptors, + api: options.api, + discovery: options.discovery, + }); + if (innerInvocation != null) { + skillInvocation = { + ...innerInvocation, + oneShot: { + ...(parsed.modelString != null ? { modelString: parsed.modelString } : {}), + ...(parsed.thinkingLevel != null ? { thinkingLevel: parsed.thinkingLevel } : {}), + }, + }; + } + } + return { parsed: skillInvocation == null ? parsed : null, skillInvocation, diff --git a/src/browser/features/Settings/Sections/ModelClassesEditor.tsx b/src/browser/features/Settings/Sections/ModelClassesEditor.tsx new file mode 100644 index 0000000000..367f34de50 --- /dev/null +++ b/src/browser/features/Settings/Sections/ModelClassesEditor.tsx @@ -0,0 +1,237 @@ +import { X } from "lucide-react"; + +import { Button } from "@/browser/components/Button/Button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/browser/components/SelectPrimitive/SelectPrimitive"; +import { TooltipIfPresent } from "@/browser/components/Tooltip/Tooltip"; +import { useModelClasses } from "@/browser/hooks/useModelClasses"; +import { useModelsFromSettings } from "@/browser/hooks/useModelsFromSettings"; +import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; +import { useRouting } from "@/browser/hooks/useRouting"; +import { + getThinkingOptionLabel, + parseThinkingInput, + type ThinkingLevel, +} from "@/common/types/thinking"; +import { isModelServableWithProvidersConfig } from "@/common/utils/ai/modelAvailability"; +import { normalizeToCanonical } from "@/common/utils/ai/models"; +import { + buildModelClassValue, + CANONICAL_MODEL_CLASSES, + parseModelClassValue, + splitModelClassValue, +} from "@/common/utils/ai/skillModelClasses"; +import { getThinkingPolicyForModel, resolveThinkingInput } from "@/common/utils/thinking/policy"; + +const MODEL_SELECT_TRIGGER_CLASS = + "border-border-medium bg-background-secondary hover:bg-hover h-7 w-64 cursor-pointer rounded-md border px-2 text-xs transition-colors"; +const THINKING_SELECT_TRIGGER_CLASS = + "border-border-medium bg-background-secondary hover:bg-hover h-7 w-28 cursor-pointer rounded-md border px-2 text-xs transition-colors"; + +/** Sentinel Select value for "no thinking suffix" (Radix rejects empty item values). */ +const THINKING_DEFAULT_OPTION = "default"; + +/** + * Model class editor (Settings → Models). + * + * Model classes are the indirection behind per-skill model routing: skills + * bind to a class name (frontmatter `metadata: model-class`, or the + * `skillModelClasses` table in config.json) and the class maps to a concrete + * model here. When models change, updating the class re-routes every bound + * skill at once. + * + * The editor surfaces exactly the canonical classes (large/medium/small) so + * skill bindings stay portable across machines; hand-edited custom classes in + * config.json keep working and are preserved on save, but are not editable + * here. + */ +export function ModelClassesEditor() { + const { modelClasses, loaded: classesLoaded, pendingWrites, setModelClass } = useModelClasses(); + const { models } = useModelsFromSettings(); + const { config: providersConfig } = useProvidersConfig(); + const routing = useRouting(); + + // Settings can list aliases that canonicalize to the same model; dedupe via + // canonical form so SelectItem values stay unique. + const modelCandidates = Array.from(new Set(models.map((model) => normalizeToCanonical(model)))); + + const canonicalNames: readonly string[] = CANONICAL_MODEL_CLASSES; + const customEntries = Object.entries(modelClasses) + .filter(([name]) => !canonicalNames.includes(name)) + .sort(([a], [b]) => a.localeCompare(b)); + + const renderClassRow = (className: string) => { + const rawValue = modelClasses[className]; + const parsed = rawValue ? parseModelClassValue(rawValue) : null; + const { thinkingSuffix } = rawValue ? splitModelClassValue(rawValue) : { thinkingSuffix: null }; + const selectedModel = parsed?.model ?? ""; + // Show numeric (model-relative) suffixes as the level they resolve to for + // the selected model; re-saving through the select writes the named level. + // providersConfig resolves mappedToModel aliases — the send-path resolver + // passes it too, so the ladder shown here matches what routing will use. + const selectedThinking: ThinkingLevel | null = + parsed?.thinkingLevel != null && parsed.model + ? resolveThinkingInput(parsed.thinkingLevel, parsed.model, providersConfig) + : null; + const thinkingOptions = selectedModel + ? getThinkingPolicyForModel(selectedModel, providersConfig) + : []; + // On a model switch, carry the raw thinking suffix only when it stays + // meaningful: numeric suffixes are model-relative by design, named levels + // must exist in the new model's ladder, and an unparseable suffix (the + // "invalid value" repair case) is dropped so picking a model actually + // fixes the row instead of re-persisting a value sanitization-era builds + // would have deleted. + const carrySuffixTo = (nextModel: string): string | null => { + if (parsed == null || thinkingSuffix == null) { + return null; + } + const parsedSuffix = parseThinkingInput(thinkingSuffix); + if (parsedSuffix == null) { + return null; + } + if (typeof parsedSuffix === "number") { + return thinkingSuffix; + } + return getThinkingPolicyForModel(nextModel, providersConfig).includes(parsedSuffix) + ? thinkingSuffix + : null; + }; + // Ensure the selected model is offerable even if hidden from the picker + // list (e.g. a hand-configured custom model). + const rowModelCandidates = + selectedModel && !modelCandidates.includes(selectedModel) + ? [selectedModel, ...modelCandidates] + : modelCandidates; + // Proactive churn warning: the class points at a model no configured + // route can serve (skill sends bound to it will fail with the same + // verdict). Null providersConfig = still loading — say nothing yet. The + // verdict also waits for routing.loaded: judging against the default + // ["direct"] priority would flash a false warning on gateway-routed + // setups every time Settings opens. + const modelUnavailable = + parsed != null && + providersConfig != null && + routing.loaded && + !isModelServableWithProvidersConfig({ + canonicalModel: parsed.model, + routePriority: routing.routePriority, + routeOverrides: routing.routeOverrides, + providersConfig, + }); + // State publishes on the write's ack, so a second edit made before the ack + // would compose against the still-old rendered value and overwrite the + // first edit. Disable the row's controls until its write settles. + const rowWritePending = (pendingWrites[className] ?? 0) > 0; + const rowDisabled = !classesLoaded || rowWritePending; + + return ( +
+ {className} + + + {rawValue !== undefined && ( + + )} + {rawValue !== undefined && parsed == null && ( + + invalid value: {rawValue} + + )} + {modelUnavailable && ( + + no configured route can serve this model — update this class + + )} +
+ ); + }; + + return ( +
+
Model Classes
+

+ Size classes for per-skill model routing. A skill bound to a class (frontmatter{" "} + metadata: model-class, or the{" "} + skillModelClasses table in config.json) runs on the + class's model for that invocation only — your workspace model is untouched. When models + change, update the class here and every bound skill follows. +

+ +
{canonicalNames.map((name) => renderClassRow(name))}
+ + {customEntries.length > 0 && ( +

+ Custom classes (edit in config.json):{" "} + {customEntries.map(([name, value]) => `${name} → ${value}`).join(", ")} +

+ )} +
+ ); +} diff --git a/src/browser/features/Settings/Sections/ModelClassesEditor.ui.test.tsx b/src/browser/features/Settings/Sections/ModelClassesEditor.ui.test.tsx new file mode 100644 index 0000000000..bc9ccfdf8e --- /dev/null +++ b/src/browser/features/Settings/Sections/ModelClassesEditor.ui.test.tsx @@ -0,0 +1,156 @@ +import { cleanup, render, fireEvent, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { installDom } from "../../../../../tests/ui/dom"; + +let apiMock: { + config: { + getConfig: ReturnType; + updateModelClasses: ReturnType; + onConfigChanged: ReturnType; + }; +} | null = null; + +/** Providers map for the availability warning; null = still loading (warning suppressed). */ +let providersConfigMock: Record | null = + null; + +void mock.module("@/browser/contexts/API", () => ({ + useOptionalAPI: () => (apiMock ? { api: apiMock } : null), + // useRouting (imported by the editor) reads the API through useAPI. + useAPI: () => ({ api: apiMock }), +})); + +void mock.module("@/browser/hooks/useProvidersConfig", () => ({ + useProvidersConfig: () => ({ config: providersConfigMock, loading: providersConfigMock == null }), +})); + +void mock.module("@/browser/hooks/useModelsFromSettings", () => ({ + useModelsFromSettings: () => ({ + models: ["anthropic:claude-haiku-4-5", "anthropic:claude-sonnet-5", "anthropic:claude-fable-5"], + hiddenModelsForSelector: [], + }), +})); + +import { ModelClassesEditor } from "./ModelClassesEditor"; + +function createApiMock(modelClasses: Record) { + return { + config: { + getConfig: mock(() => Promise.resolve({ modelClasses })), + updateModelClasses: mock(() => Promise.resolve(undefined)), + onConfigChanged: mock((_input: undefined, _opts: { signal?: AbortSignal }) => + Promise.resolve( + (async function* (): AsyncGenerator { + // Subscription that ends immediately: the hook's initial fetch has + // already run; these tests drive state via direct interactions. + await Promise.resolve(); + yield* [] as void[]; + })() + ) + ), + }, + }; +} + +describe("ModelClassesEditor", () => { + let restoreDom: (() => void) | null = null; + + beforeEach(() => { + restoreDom = installDom(); + }); + + afterEach(() => { + cleanup(); + restoreDom?.(); + restoreDom = null; + apiMock = null; + providersConfigMock = null; + }); + + test("renders the three canonical class rows; clear button only on configured classes", async () => { + apiMock = createApiMock({ small: "anthropic:claude-haiku-4-5+0" }); + // Row presence is asserted via the labeled row groups, not the select + // triggers: other suites in the same process (TasksSection) mock + // SelectPrimitive with native elements, and bun's mock.module leaks across + // test files, so select internals are not stable to assert on. + const { getByRole, queryByLabelText } = render(); + + await waitFor(() => { + expect(apiMock?.config.getConfig).toHaveBeenCalled(); + expect(queryByLabelText("Clear model class small")).not.toBeNull(); + }); + + for (const name of ["large", "medium", "small"]) { + expect(getByRole("group", { name: `Model class ${name}` })).toBeTruthy(); + } + // Unset classes have nothing to clear. + expect(queryByLabelText("Clear model class large")).toBeNull(); + expect(queryByLabelText("Clear model class medium")).toBeNull(); + }); + + test("clearing a canonical class preserves hand-edited custom classes in the write", async () => { + apiMock = createApiMock({ + small: "anthropic:claude-haiku-4-5+0", + "my-custom": "anthropic:claude-fable-5+max", + }); + const { getByLabelText, queryByLabelText } = render(); + + await waitFor(() => expect(queryByLabelText("Clear model class small")).not.toBeNull()); + fireEvent.click(getByLabelText("Clear model class small")); + + await waitFor(() => expect(apiMock?.config.updateModelClasses).toHaveBeenCalled()); + expect(apiMock?.config.updateModelClasses).toHaveBeenCalledWith({ + modelClasses: { "my-custom": "anthropic:claude-fable-5+max" }, + }); + }); + + test("edits preserve custom classes this build cannot parse", async () => { + // "my-local-llm" has no provider prefix, so parseModelClassValue rejects + // it — the write must still carry it verbatim rather than deleting the + // user's hand-edited entry as a side effect of clearing another row. + apiMock = createApiMock({ + small: "anthropic:claude-haiku-4-5+0", + tiny: "my-local-llm", + }); + const { getByLabelText, queryByLabelText } = render(); + + await waitFor(() => expect(queryByLabelText("Clear model class small")).not.toBeNull()); + fireEvent.click(getByLabelText("Clear model class small")); + + await waitFor(() => expect(apiMock?.config.updateModelClasses).toHaveBeenCalled()); + expect(apiMock?.config.updateModelClasses).toHaveBeenCalledWith({ + modelClasses: { tiny: "my-local-llm" }, + }); + }); + + test("lists custom classes as config-managed instead of hiding them", async () => { + apiMock = createApiMock({ "my-custom": "anthropic:claude-fable-5+max" }); + const { findByText } = render(); + + expect(await findByText(/my-custom → anthropic:claude-fable-5\+max/)).toBeTruthy(); + }); + + test("flags an unparseable configured value instead of silently dropping it", async () => { + apiMock = createApiMock({ small: "garbage" }); + const { findByText } = render(); + + expect(await findByText(/invalid value: garbage/)).toBeTruthy(); + }); + + test("warns when no configured route can serve a class model", async () => { + apiMock = createApiMock({ small: "anthropic:claude-haiku-4-5+0" }); + providersConfigMock = { anthropic: { isConfigured: false } }; + const { findByText } = render(); + + expect(await findByText(/no configured route can serve this model/)).toBeTruthy(); + }); + + test("does not warn when the class model has a configured route", async () => { + apiMock = createApiMock({ small: "anthropic:claude-haiku-4-5+0" }); + providersConfigMock = { anthropic: { isConfigured: true, isEnabled: true } }; + const { queryByText, queryByLabelText } = render(); + + await waitFor(() => expect(queryByLabelText("Clear model class small")).not.toBeNull()); + expect(queryByText(/no configured route can serve this model/)).toBeNull(); + }); +}); diff --git a/src/browser/features/Settings/Sections/ModelsSection.stories.tsx b/src/browser/features/Settings/Sections/ModelsSection.stories.tsx index 07df912205..70e7d2a036 100644 --- a/src/browser/features/Settings/Sections/ModelsSection.stories.tsx +++ b/src/browser/features/Settings/Sections/ModelsSection.stories.tsx @@ -70,6 +70,13 @@ export const ModelsConfigured: Story = { ); return setupSettingsStory({ + modelClasses: { + large: "anthropic:claude-opus-4-8+max", + // xai is deliberately unconfigured in this story: exercises the + // "no configured route" warning on the medium row. + medium: "xai:grok-beta", + small: "anthropic:claude-sonnet-4-20250514+0", + }, providersConfig: { anthropic: { apiKeySet: true, @@ -121,3 +128,22 @@ export const ModelsConfigured: Story = { ); }, }; + +/** + * Pinned phone-width snapshot of the configured state: the Model Classes rows + * wrap their label/select layout at narrow widths, and without an explicit + * Pixel phone variant CI would only ever snapshot the desktop layout the + * wrapping is meant to protect against. globals.viewport mirrors the Pixel + * matrix so local Storybook shows the same width. + */ +export const ModelsConfiguredPhone: Story = { + ...ModelsConfigured, + globals: { + viewport: { value: "mobile1", isRotated: false }, + }, + parameters: { + pixel: { + matrix: { themes: ["dark", "light"], viewports: ["phone"] }, + }, + }, +}; diff --git a/src/browser/features/Settings/Sections/ModelsSection.tsx b/src/browser/features/Settings/Sections/ModelsSection.tsx index 0f4ed91133..5f553256ea 100644 --- a/src/browser/features/Settings/Sections/ModelsSection.tsx +++ b/src/browser/features/Settings/Sections/ModelsSection.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { ArrowRight, Info, Loader2, Plus, ShieldCheck } from "lucide-react"; import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; import { Button } from "@/browser/components/Button/Button"; +import { ModelClassesEditor } from "./ModelClassesEditor"; import { ModelFallbacksEditor } from "./ModelFallbacksEditor"; import { ProviderIcon } from "@/browser/components/ProviderIcon/ProviderIcon"; import { @@ -592,6 +593,8 @@ export function ModelsSection() { + +
diff --git a/src/browser/features/Settings/Sections/settingsStoryUtils.tsx b/src/browser/features/Settings/Sections/settingsStoryUtils.tsx index c53c6415f2..7dad5b688b 100644 --- a/src/browser/features/Settings/Sections/settingsStoryUtils.tsx +++ b/src/browser/features/Settings/Sections/settingsStoryUtils.tsx @@ -124,6 +124,8 @@ export function SettingsSectionStory(props: SettingsSectionStoryProps) { interface SetupSettingsStoryOptions { layoutPresets?: LayoutPresetsConfig; + /** Initial model classes for Settings → Models → Model Classes. */ + modelClasses?: Record; providersConfig?: Record< string, { @@ -178,6 +180,7 @@ export function setupSettingsStory(options: SetupSettingsStoryOptions): APIClien taskSettings: options.taskSettings, serverAuthSessions: options.serverAuthSessions, layoutPresets: options.layoutPresets, + modelClasses: options.modelClasses, }); } diff --git a/src/browser/hooks/useCompactAndRetry.ts b/src/browser/hooks/useCompactAndRetry.ts index c5b7c2c1aa..c28e25bbf8 100644 --- a/src/browser/hooks/useCompactAndRetry.ts +++ b/src/browser/hooks/useCompactAndRetry.ts @@ -15,6 +15,8 @@ import { type CompactionSuggestion, } from "@/browser/utils/compaction/suggestion"; import { executeCompaction } from "@/browser/utils/chatCommands"; +import { parseCommand } from "@/browser/utils/slashCommands/parser"; +import { resolveThinkingInput } from "@/common/utils/thinking/policy"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { AGENT_AI_DEFAULTS_KEY } from "@/common/constants/storage"; import type { FilePart, ProvidersConfigMap } from "@/common/orpc/types"; @@ -52,15 +54,60 @@ function findTriggerUserMessage( * Preserves skill metadata if the original message was a skill invocation. */ function buildFollowUpFromSource( - source: Extract + source: Extract, + ctx: { providersConfig: ProvidersConfigMap | null; currentModel: string | null } ): CompactionFollowUpInput { + // A composed one-shot skill send ("/haiku+0 /done args") stores the full + // typed text as content; re-parse it so the rebuilt follow-up keeps the + // explicit model AND thinking overrides instead of falling back to class + // routing / ambient workspace thinking. + // trimStart matches parseCommand's own tolerance for leading whitespace — + // a column-zero guard would silently drop the preserved one-shot. + const parsedOneShot = + source.agentSkill && source.content.trimStart().startsWith("/") + ? parseCommand(source.content) + : null; + const oneShot = parsedOneShot?.type === "model-oneshot" ? parsedOneShot : null; + const oneShotModel = oneShot?.modelString; + const rawThinking = oneShot?.thinkingLevel; + + // Numeric thinking is model-relative. With an explicit model it resolves + // right here; without one the send may get class-routed, so the raw index + // rides along for the backend to resolve against whatever model streams + // (the resolved fallback below applies only if routing doesn't happen). + let thinkingLevel: CompactionFollowUpInput["thinkingLevel"]; + let oneShotThinkingIndex: number | undefined; + if (rawThinking != null) { + if (typeof rawThinking !== "number") { + thinkingLevel = rawThinking; + } else if (oneShotModel != null) { + thinkingLevel = resolveThinkingInput(rawThinking, oneShotModel, ctx.providersConfig); + } else { + oneShotThinkingIndex = rawThinking; + thinkingLevel = ctx.currentModel + ? resolveThinkingInput(rawThinking, ctx.currentModel, ctx.providersConfig) + : undefined; + } + } + const carriesOneShot = oneShotModel != null || rawThinking != null; + return { text: source.content, fileParts: source.fileParts, reviews: source.reviews, + ...(oneShotModel != null ? { model: oneShotModel, skipSkillModelRouting: true } : {}), + ...(thinkingLevel != null ? { thinkingLevel } : {}), + ...(oneShotThinkingIndex != null ? { oneShotThinkingIndex } : {}), + // The original one-shot send never persisted its overrides as workspace + // defaults; the re-dispatch must not either. + ...(carriesOneShot ? { skipAiSettingsPersistence: true } : {}), muxMetadata: source.agentSkill ? buildAgentSkillMetadata({ rawCommand: source.content, + // Preserve the displayed prefix (composed one-shots render + // "/haiku+0 /done") — without it the rebuilt transcript loses the + // command badge, which keys its highlighting on this value. + commandPrefix: source.commandPrefix, skillName: source.agentSkill.skillName, scope: source.agentSkill.scope, arguments: source.agentSkill.arguments, @@ -274,7 +321,10 @@ export function useCompactAndRetry(props: { workspaceId: string }): CompactAndRe } // For normal messages (not /compact), build follow-up content directly. - const followUpContent = buildFollowUpFromSource(source); + const followUpContent = buildFollowUpFromSource(source, { + providersConfig, + currentModel: workspaceState?.currentModel ?? null, + }); const result = await executeCompaction({ api, workspaceId: props.workspaceId, @@ -296,7 +346,14 @@ export function useCompactAndRetry(props: { workspaceId: string }): CompactAndRe setIsRetryingWithCompaction(false); } } - }, [api, compactionSuggestion, props.workspaceId, triggerUserMessage]); + }, [ + api, + compactionSuggestion, + props.workspaceId, + triggerUserMessage, + providersConfig, + workspaceState?.currentModel, + ]); /** * Auto-compact on context_exceeded. Runs silently - never touches chat input. @@ -313,7 +370,10 @@ export function useCompactAndRetry(props: { workspaceId: string }): CompactAndRe try { const sendMessageOptions = getSendOptionsFromStorage(props.workspaceId); - const followUpContent = buildFollowUpFromSource(triggerUserMessage); + const followUpContent = buildFollowUpFromSource(triggerUserMessage, { + providersConfig, + currentModel: workspaceState?.currentModel ?? null, + }); const result = await executeCompaction({ api, @@ -336,7 +396,14 @@ export function useCompactAndRetry(props: { workspaceId: string }): CompactAndRe setIsRetryingWithCompaction(false); } } - }, [api, compactionSuggestion?.modelId, props.workspaceId, triggerUserMessage]); + }, [ + api, + compactionSuggestion?.modelId, + props.workspaceId, + triggerUserMessage, + providersConfig, + workspaceState?.currentModel, + ]); // Auto-trigger compaction on context_exceeded for seamless recovery. // Only auto-compact if we have a compaction suggestion; otherwise show manual UI. diff --git a/src/browser/hooks/useModelClasses.ts b/src/browser/hooks/useModelClasses.ts new file mode 100644 index 0000000000..000c06df99 --- /dev/null +++ b/src/browser/hooks/useModelClasses.ts @@ -0,0 +1,179 @@ +import { useEffect, useRef, useState } from "react"; +import { useOptionalAPI } from "@/browser/contexts/API"; + +export interface ModelClassesState { + /** Class name → model value in one-shot syntax ("haiku+0"). */ + modelClasses: Record; + /** + * True once the first config fetch has landed. Writes are full-map + * replacements built from local state, so editing before the initial load + * would persist a near-empty map and wipe every not-yet-fetched class — + * consumers must gate their controls on this. + */ + loaded: boolean; + /** + * Classes with a write still in flight (state publishes on the write's + * ack). Editors must disable a pending row's controls: a second edit built + * from the still-unpublished rendered state would compose against the old + * value and overwrite the first edit. + */ + pendingWrites: Record; + // Arrow-function property type so consumers can destructure without + // tripping @typescript-eslint/unbound-method. + /** Set (or clear, with null/empty) one class's model value. */ + setModelClass: (className: string, value: string | null) => void; +} + +/** + * Reads/writes the model-classes map (skill routing indirection) from app + * config. Mirrors useModelFallbacks: fetch on mount, subscribe to config + * changes, optimistically apply local edits while ignoring stale fetches. + * Writes are full-map replacements, so hand-edited custom classes survive + * edits made through the Settings editor. + */ +export function useModelClasses(): ModelClassesState { + const api = useOptionalAPI()?.api ?? null; + const [modelClasses, setMap] = useState>({}); + const [loaded, setLoaded] = useState(false); + // Ignore stale config fetches so backend refreshes can't overwrite newer optimistic edits. + const fetchVersionRef = useRef(0); + // Populated by the subscription effect below; lets the write-failure revert + // in setModelClass reuse the same stale-guarded fetch. A ref (not a + // useCallback) keeps this within the repo's React Compiler conventions — + // no manual memoization for identity stabilization. + const refetchRef = useRef<() => void>(() => { + // No-op until the subscription effect installs the real fetch. + }); + // Newest intended map across not-yet-persisted edits: serialized writes each + // build on the latest intent, not on the still-unpublished state. + const pendingMapRef = useRef | null>(null); + // Serializes writes so rapid edits persist in order and the last one wins. + const writeChainRef = useRef>(Promise.resolve()); + // Per-class in-flight write counts; consumers disable pending rows. + const [pendingWrites, setPendingWrites] = useState>({}); + + useEffect(() => { + const getConfig = api?.config?.getConfig; + const onConfigChanged = api?.config?.onConfigChanged; + if (!getConfig || !onConfigChanged) { + return; + } + + const fetchConfig = async () => { + const fetchVersion = ++fetchVersionRef.current; + try { + const config = await getConfig(); + if (fetchVersion !== fetchVersionRef.current) { + return; + } + setMap(config.modelClasses ?? {}); + setLoaded(true); + } catch { + // Best-effort only. + } + }; + refetchRef.current = () => void fetchConfig(); + + const abortController = new AbortController(); + const { signal } = abortController; + let iterator: AsyncIterator | null = null; + + void fetchConfig(); + + (async () => { + try { + const subscribedIterator = await onConfigChanged(undefined, { signal }); + if (signal.aborted) { + void subscribedIterator.return?.(); + return; + } + iterator = subscribedIterator; + for await (const _ of subscribedIterator) { + if (signal.aborted) { + break; + } + void fetchConfig(); + } + } catch { + // Subscription cancelled via abort signal - expected on cleanup. + } + })(); + + return () => { + abortController.abort(); + void iterator?.return?.(); + }; + }, [api]); + + const setModelClass = (className: string, value: string | null) => { + const key = className.trim(); + // Writes are full-map replacements from local state: refuse before the + // initial fetch lands, or an early edit would wipe every class the + // fetch would have revealed. + if (!key || !loaded) { + return; + } + + // Only the edited entry is touched. Deliberately no map-wide + // sanitization: hand-edited entries the current build cannot parse + // (custom models, future syntax) must survive edits made through the + // Settings editor — a bound-but-unparseable class already fails loudly + // at send time and is flagged inline by the editor. + const base = pendingMapRef.current ?? modelClasses; + const next = { ...base }; + const trimmed = value?.trim() ?? ""; + if (!trimmed) { + delete next[key]; + } else { + next[key] = trimmed; + } + pendingMapRef.current = next; + + // Guarded lookup rather than a chained call: in partial-API environments + // (story mocks, tests) a missing route must not throw synchronously. + const updateModelClasses = api?.config?.updateModelClasses; + if (!updateModelClasses) { + pendingMapRef.current = null; + return; + } + + setPendingWrites((current) => ({ ...current, [key]: (current[key] ?? 0) + 1 })); + + // Persist BEFORE publishing: routing reads the backend map at send time, + // so optimistically advertising the new mapping would let a quick + // follow-up skill invocation stream on the OLD route while the editor + // claims the new one. The selects update on the write's ack instead. + writeChainRef.current = writeChainRef.current + .then(async () => { + await updateModelClasses({ modelClasses: next }); + // Newer than any in-flight fetch: the ack is the freshest truth. + fetchVersionRef.current++; + setMap(next); + }) + .catch(() => { + // If the write fails, re-fetch so the UI reverts to the backend's + // actual map rather than displaying classes routing never applies. + refetchRef.current(); + }) + .finally(() => { + if (pendingMapRef.current === next) { + pendingMapRef.current = null; + } + setPendingWrites((current) => { + const count = (current[key] ?? 0) - 1; + if (count > 0) { + return { ...current, [key]: count }; + } + const { [key]: _drop, ...rest } = current; + return rest; + }); + }); + }; + + return { + modelClasses, + loaded, + pendingWrites, + setModelClass, + }; +} diff --git a/src/browser/hooks/useRouting.ts b/src/browser/hooks/useRouting.ts index 08047bce38..56ca0fd91a 100644 --- a/src/browser/hooks/useRouting.ts +++ b/src/browser/hooks/useRouting.ts @@ -8,7 +8,10 @@ import { type RouteContext, } from "@/common/routing"; import { normalizeToCanonical } from "@/common/utils/ai/models"; -import { isGatewayModelAccessibleFromAuthoritativeCatalog } from "@/common/utils/providers/gatewayModelCatalog"; +import { + isRouteGatewayModelAccessible, + isRouteProviderConfigured, +} from "@/common/utils/ai/modelAvailability"; import { useProvidersConfig } from "./useProvidersConfig"; @@ -31,6 +34,13 @@ export interface RoutingState { routePriority: string[]; /** Per-model route overrides */ routeOverrides: Record; + /** + * True once the routing config fetch has landed. Until then routePriority + * is the built-in default — availability verdicts computed against it can + * be wrong for gateway-routed setups, so consumers gating UI on route + * reachability must wait for this. + */ + loaded: boolean; /** What route will be used for a given canonical model? */ resolveRoute(canonicalModel: string): { @@ -63,6 +73,7 @@ export function useRouting(): RoutingState { const { api } = useAPI(); const { config: providersConfig } = useProvidersConfig(); const [routePriority, setRoutePriorityState] = useState(DEFAULT_ROUTE_PRIORITY); + const [loaded, setLoaded] = useState(false); const [routeOverrides, setRouteOverridesState] = useState>({}); // Ignore stale config fetches so backend refreshes can't overwrite newer optimistic edits. const fetchVersionRef = useRef(0); @@ -83,6 +94,7 @@ export function useRouting(): RoutingState { setRoutePriorityState(config.routePriority ?? DEFAULT_ROUTE_PRIORITY); setRouteOverridesState(config.routeOverrides ?? {}); + setLoaded(true); } catch { // Best-effort only. } @@ -128,22 +140,18 @@ export function useRouting(): RoutingState { }; }, [api, fetchRoutingConfig]); + // Shared predicates: the send-path availability check + // (isModelServableWithProvidersConfig) uses these same definitions, so the + // Settings picker and skill-routing verdicts cannot drift apart. const isConfigured = useCallback( (provider: string) => - providersConfig?.[provider]?.isConfigured === true && - providersConfig?.[provider]?.isEnabled !== false, + providersConfig != null && isRouteProviderConfigured(providersConfig, provider), [providersConfig] ); const isGatewayModelAccessible = useCallback( (gateway: string, modelId: string) => - isGatewayModelAccessibleFromAuthoritativeCatalog( - gateway, - modelId, - providersConfig?.[gateway]?.models, - providersConfig?.[gateway]?.discoveredModels, - providersConfig?.[gateway]?.removedModels - ), + providersConfig == null || isRouteGatewayModelAccessible(providersConfig, gateway, modelId), [providersConfig] ); @@ -263,5 +271,6 @@ export function useRouting(): RoutingState { setRoutePreferences, setRoutePriority, setRouteOverride, + loaded, }; } diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 7ec8973c54..27ffa315f0 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -145,6 +145,8 @@ export interface MockORPCClientOptions { agentDefinitions?: AgentDefinitionDescriptor[]; /** Initial per-subagent AI defaults for config.getConfig (e.g., Settings → Tasks section) */ subagentAiDefaults?: SubagentAiDefaults; + /** Initial model classes for config.getConfig (Settings → Models → Model Classes) */ + modelClasses?: Record; /** Coder lifecycle preferences for config.getConfig (e.g., Settings → Coder section) */ coderWorkspaceArchiveBehavior?: CoderWorkspaceArchiveBehavior; /** What to do with mux-managed worktrees when archiving a chat. */ @@ -391,6 +393,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl taskSettings: initialTaskSettings, subagentAiDefaults: initialSubagentAiDefaults, agentAiDefaults: initialAgentAiDefaults, + modelClasses: initialModelClasses, coderWorkspaceArchiveBehavior: initialCoderWorkspaceArchiveBehavior = "stop", worktreeArchiveBehavior: initialWorktreeArchiveBehavior = "keep", chatTranscriptFullWidth: initialChatTranscriptFullWidth = false, @@ -640,6 +643,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl let layoutPresets = initialLayoutPresets ?? DEFAULT_LAYOUT_PRESETS_CONFIG; let subagentAiDefaults = deriveSubagentAiDefaults(); + let modelClasses: Record | undefined = initialModelClasses; const mockStats: ChatStats = { consumers: [], @@ -776,6 +780,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl defaultRuntime, agentAiDefaults, subagentAiDefaults, + modelClasses, muxGovernorUrl, heartbeatDefaultPrompt, heartbeatDefaultIntervalMs, @@ -842,6 +847,12 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl notifyConfigChanged(); return Promise.resolve(undefined); }, + updateModelClasses: (input: { modelClasses: Record }) => { + modelClasses = + Object.keys(input.modelClasses).length > 0 ? { ...input.modelClasses } : undefined; + notifyConfigChanged(); + return Promise.resolve(undefined); + }, updateMuxGatewayPrefs: (input: { muxGatewayEnabled: boolean; muxGatewayModels: string[]; diff --git a/src/browser/utils/chatCommands.test.ts b/src/browser/utils/chatCommands.test.ts index ed157332b1..b9b4b204d3 100644 --- a/src/browser/utils/chatCommands.test.ts +++ b/src/browser/utils/chatCommands.test.ts @@ -1582,6 +1582,59 @@ describe("prepareCompactionMessage", () => { expect(metadata.parsed.followUpContent?.agentId).toBe("exec"); }); + test("carried one-shot overrides win over ambient preserved send options", () => { + // A compact-and-retry rebuild of "/haiku+0 /skill" carries the one-shot's + // model, thinking, and persistence semantics in followUpContent; the + // ambient stored options (different thinking, no skip flags) must not + // clobber them. + const sendMessageOptions = createBaseOptions(); + + const { metadata } = prepareCompactionMessage({ + workspaceId: "ws-1", + followUpContent: { + text: "/haiku+0 /done finish up", + model: "anthropic:claude-3-5-haiku", + skipSkillModelRouting: true, + thinkingLevel: "off", + skipAiSettingsPersistence: true, + }, + sendMessageOptions, + }); + + expectCompactionMetadata(metadata); + + const followUp = metadata.parsed.followUpContent; + expect(followUp?.model).toBe("anthropic:claude-3-5-haiku"); + expect(followUp?.skipSkillModelRouting).toBe(true); + expect(followUp?.thinkingLevel).toBe("off"); + expect(followUp?.skipAiSettingsPersistence).toBe(true); + }); + + test("a thinking-only carried one-shot keeps its raw index for routed re-resolution", () => { + const sendMessageOptions = createBaseOptions(); + + const { metadata } = prepareCompactionMessage({ + workspaceId: "ws-1", + followUpContent: { + text: "/+0 /done finish up", + thinkingLevel: "medium", + oneShotThinkingIndex: 0, + skipAiSettingsPersistence: true, + }, + sendMessageOptions, + }); + + expectCompactionMetadata(metadata); + + const followUp = metadata.parsed.followUpContent; + // No model override: the re-dispatch stays routable... + expect(followUp?.model).toBe("anthropic:claude-sonnet-4-6"); + expect(followUp?.skipSkillModelRouting).toBeUndefined(); + // ...and the raw index survives so the backend can re-ladder it. + expect(followUp?.oneShotThinkingIndex).toBe(0); + expect(followUp?.thinkingLevel).toBe("medium"); + }); + test("does not create followUpContent when no text or images provided", () => { const sendMessageOptions = createBaseOptions(); const { metadata } = prepareCompactionMessage({ diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index a279d5c54a..85b0693254 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -1497,6 +1497,24 @@ export function prepareCompactionMessage(options: CompactionOptions): { model: existingModel ?? options.sendMessageOptions.model, agentId: existingAgentId ?? options.sendMessageOptions.agentId ?? WORKSPACE_DEFAULTS.agentId, ...pickPreservedSendOptions(options.sendMessageOptions), + // One-shot overrides reconstructed from the original send (model / + // thinking carried by the follow-up content) must win over the ambient + // stored options that pickPreservedSendOptions just spread — otherwise a + // compact-and-retry of "/haiku+0 /skill" re-dispatches with the + // workspace's thinking and persistence semantics instead of the + // one-shot's. + ...(options.followUpContent.thinkingLevel != null + ? { thinkingLevel: options.followUpContent.thinkingLevel } + : {}), + ...(options.followUpContent.oneShotThinkingIndex != null + ? { oneShotThinkingIndex: options.followUpContent.oneShotThinkingIndex } + : {}), + ...(options.followUpContent.skipSkillModelRouting != null + ? { skipSkillModelRouting: options.followUpContent.skipSkillModelRouting } + : {}), + ...(options.followUpContent.skipAiSettingsPersistence != null + ? { skipAiSettingsPersistence: options.followUpContent.skipAiSettingsPersistence } + : {}), }; } diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts index 33ebbf24f4..ffb73849b7 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -130,6 +130,20 @@ export const AppConfigOnDiskSchema = z * runtime sanitization rules (drop self, de-dupe, cap length). */ modelFallbacks: ModelFallbacksSchema.optional(), + /** + * Named model classes (e.g. large/medium/small → a model alias or + * "provider:model" id with an optional "+thinking" suffix, one-shot + * syntax). Indirection layer so bindings survive model churn; consumed by + * per-skill model routing (see skillModelClasses and the skill frontmatter + * metadata "model-class" key). + */ + modelClasses: z.record(z.string(), z.string()).optional(), + /** + * Per-skill routing table (skill name → class name in modelClasses). + * Wins over a skill's own frontmatter metadata "model-class" binding, so + * skills the user does not own can be routed without editing them. + */ + skillModelClasses: z.record(z.string(), z.string()).optional(), defaultModel: z.string().optional(), advisorModelString: z.string().optional(), advisorThinkingLevel: ThinkingLevelSchema.optional(), diff --git a/src/common/constants/ui.ts b/src/common/constants/ui.ts index 9a7a4c7443..f9f4228d40 100644 --- a/src/common/constants/ui.ts +++ b/src/common/constants/ui.ts @@ -69,6 +69,18 @@ Write in a factual, dense style. Every sentence should convey essential context. */ export const FORCE_COMPACTION_BUFFER_PERCENT = 5; +/** + * Headroom (percentage points of the routed model's context window) reserved + * for the pending turn when deciding whether a skill-routed send must compact + * first. Routed sends deliberately ignore the user's compaction threshold — + * a cheap skill invocation must not force an unrequested workspace-wide + * compaction of a history far under the workspace model's limit — but the + * recorded usage excludes the new message, attachments, and skill snapshot, + * so requiring a full 100% would let a near-limit history overrun the routed + * model at request startup instead of compacting. + */ +export const ROUTED_SEND_COMPACTION_HEADROOM_PERCENT = 10; + /** * Duration (ms) to show "copied" feedback after copying to clipboard */ diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 213265df48..60697926ee 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1403,7 +1403,18 @@ export const workspace = { fileParts: z.array(FilePartSchema).optional(), }), }), - output: ResultSchema(z.object({}), SendMessageErrorSchema), + output: ResultSchema( + z.object({ + // Class model applied by skill routing — lets the frontend attribute + // send telemetry to the model that actually streams. Absent when no + // routing occurred or the send was queued for later dispatch. + routedModel: z.string().optional(), + // Thinking level routing replaced (class suffix or re-resolved numeric + // one-shot); absent when the ambient thinking level applies. + routedThinkingLevel: ThinkingLevelSchema.optional(), + }), + SendMessageErrorSchema + ), }, answerAskUserQuestion: { input: z @@ -2279,6 +2290,8 @@ export const config = { routeOverrides: z.record(z.string(), z.string()).optional(), minThinkingLevelByModel: z.record(z.string(), ThinkingLevelSchema).optional(), modelFallbacks: ModelFallbacksSchema.optional(), + modelClasses: z.record(z.string(), z.string()).optional(), + skillModelClasses: z.record(z.string(), z.string()).optional(), defaultModel: z.string().optional(), advisorModelString: AdvisorModelStringSchema, advisorThinkingLevel: AdvisorThinkingLevelSchema, @@ -2364,6 +2377,17 @@ export const config = { }), output: z.void(), }, + updateModelClasses: { + input: z.object({ + // Full-map replacement keyed by class name (canonical slots are + // large/medium/small; hand-edited custom names are preserved). Values + // use the one-shot syntax ("haiku+0") and are stored verbatim — + // unparseable entries are kept (they fail loudly at send time), never + // silently dropped as a side effect of unrelated edits. + modelClasses: z.record(z.string(), z.string()), + }), + output: z.void(), + }, updateCoderPrefs: { input: z .object({ diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index 8da6114d38..e135205abe 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -788,6 +788,22 @@ export const SendMessageOptionsSchema = z.object({ * When true, skip persisting AI settings (e.g., for one-shot or compaction sends). */ skipAiSettingsPersistence: z.boolean().optional(), + /** + * Explicit model override marker: suppresses per-skill model-class routing + * for this send. Deliberately decoupled from skipAiSettingsPersistence — + * several internal senders (heartbeats, compaction, continuations) set that + * flag for persistence reasons only and must not silently lose routing. + */ + skipSkillModelRouting: z.boolean().optional(), + /** + * Raw numeric one-shot thinking index ("/+2 /skill"). Numeric thinking is + * model-relative (0 = the model's lowest allowed level), and the frontend + * resolves `thinkingLevel` against the workspace model before routing is + * known — so when skill class routing swaps the model, the backend + * re-resolves this raw index against the routed model's ladder instead of + * inheriting a level indexed on the wrong model. + */ + oneShotThinkingIndex: z.number().int().min(0).optional(), experiments: ExperimentsSchema.optional(), /** * When true, workspace-specific agent definitions are disabled. diff --git a/src/common/types/errors.ts b/src/common/types/errors.ts index cd449383db..631b31804b 100644 --- a/src/common/types/errors.ts +++ b/src/common/types/errors.ts @@ -9,6 +9,7 @@ import type { SendMessageErrorSchema, StreamErrorTypeSchema, } from "../orpc/schemas"; +import type { ThinkingLevel } from "./thinking"; /** * Discriminated union for all possible sendMessage errors. @@ -20,6 +21,21 @@ import type { */ export type SendMessageError = z.infer; +/** + * Success payload of an accepted (non-queued) send. `routedModel` is the class + * model applied by skill routing — exposed so the frontend can attribute + * send telemetry to the model that actually streams; undefined when no + * routing occurred or the send was queued for later dispatch. + * `routedThinkingLevel` is the effective thinking level the routed stream + * runs at — class suffix, re-resolved numeric one-shot, or a named/ambient + * level riding through — after per-model floor enforcement; absent only when + * the send carries no thinking level at all. + */ +export interface SendMessageAccepted { + routedModel?: string; + routedThinkingLevel?: ThinkingLevel; +} + /** * Stream error types - categorizes errors during AI streaming * Used across backend (StreamManager) and frontend (StreamErrorMessage) diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 4f4e247307..bbc1e250a4 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -42,6 +42,29 @@ export interface UserMessageContent { export interface CompactionFollowUpInput extends UserMessageContent { /** Message metadata to apply to the queued follow-up user message (e.g., preserve /skill display) */ muxMetadata?: MuxMessageMetadata; + /** + * Explicit model override reconstructed from the original send (a composed + * "/model /skill" one-shot). Without it, a compact-and-retry rebuild would + * re-dispatch the skill through class routing even though the user + * explicitly overrode the model for that invocation. + */ + model?: string; + /** Rides with `model`: an explicit one-shot must keep bypassing class routing on re-dispatch. */ + skipSkillModelRouting?: boolean; + /** + * Explicit one-shot thinking reconstructed from the original send + * ("/haiku+0 /skill", "/+high /skill"). Resolved against `model` when + * present, else against the workspace model at rebuild time. + */ + thinkingLevel?: ThinkingLevel; + /** + * Raw numeric one-shot thinking index ("/+2 /skill"). Model-relative: the + * re-dispatched send re-resolves it against the routed class model when + * skill routing applies (see SendMessageOptions.oneShotThinkingIndex). + */ + oneShotThinkingIndex?: number; + /** One-shot overrides carried through compaction must not persist as new workspace defaults. */ + skipAiSettingsPersistence?: boolean; } /** @@ -59,6 +82,8 @@ type PreservedSendOptions = Pick< | "disableWorkspaceAgents" | "allowAgentSetGoal" | "skipAiSettingsPersistence" + | "skipSkillModelRouting" + | "oneShotThinkingIndex" >; /** @@ -75,6 +100,10 @@ export function pickPreservedSendOptions(options: SendMessageOptions): Preserved disableWorkspaceAgents: options.disableWorkspaceAgents, allowAgentSetGoal: options.allowAgentSetGoal, skipAiSettingsPersistence: options.skipAiSettingsPersistence, + // A one-shot's routing bypass and raw thinking index must survive into a + // compaction follow-up, or the re-dispatch re-routes/re-ladders the send. + skipSkillModelRouting: options.skipSkillModelRouting, + oneShotThinkingIndex: options.oneShotThinkingIndex, }; } @@ -98,6 +127,13 @@ export type StartupRetrySendOptions = Pick< agentInitiated?: boolean; /** Internal goal continuation classification for startup auto-retry accounting. */ goalKind?: GoalSyntheticMessageKind; + /** + * Pre-skill-routing compaction context for routed turns (durable subset), + * restored on startup retry so the routed compaction policy survives a + * relaunch. One level deep by construction — the nested pick never + * receives a compactionBaseOptions of its own. + */ + compactionBaseOptions?: Omit; }; /** @@ -107,7 +143,8 @@ export type StartupRetrySendOptions = Pick< export function pickStartupRetrySendOptions( options: SendMessageOptions, agentInitiated?: boolean, - goalKind?: GoalSyntheticMessageKind + goalKind?: GoalSyntheticMessageKind, + compactionBaseOptions?: SendMessageOptions ): StartupRetrySendOptions { const typedMuxMetadata = options.muxMetadata as MuxMessageMetadata | undefined; const workspaceTurnMuxMetadata = @@ -127,6 +164,16 @@ export function pickStartupRetrySendOptions( ...(workspaceTurnMuxMetadata != null ? { muxMetadata: workspaceTurnMuxMetadata } : {}), ...(agentInitiated === true ? { agentInitiated: true } : {}), ...(goalKind != null ? { goalKind } : {}), + // Routed turns persist their pre-routing compaction context (durable + // fields only) so a post-relaunch retry keeps the routed compaction + // policy instead of force-compacting at the workspace threshold against + // the routed window. Absent on non-routed turns and rows written by + // older versions — both fall back to today's behavior. + ...(compactionBaseOptions != null + ? { + compactionBaseOptions: pickStartupRetrySendOptions(compactionBaseOptions), + } + : {}), }; } diff --git a/src/common/types/project.ts b/src/common/types/project.ts index 1940733d15..2b6541cf75 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -114,6 +114,19 @@ export interface ProjectsConfig { */ modelFallbacks?: ModelFallbacks; + /** + * Named model classes (e.g. large/medium/small → a model alias or + * "provider:model" id with an optional "+thinking" suffix, one-shot syntax). + * Indirection layer so bindings survive model churn; consumed by per-skill + * model routing. + */ + modelClasses?: Record; + /** + * Per-skill routing table (skill name → class name in modelClasses). Wins + * over a skill's own frontmatter metadata "model-class" binding. + */ + skillModelClasses?: Record; + /** * Default model used for new workspaces (shared via ~/.mux/config.json). * Mirrors the browser localStorage cache (DEFAULT_MODEL_KEY). diff --git a/src/common/utils/ai/modelAvailability.test.ts b/src/common/utils/ai/modelAvailability.test.ts new file mode 100644 index 0000000000..68cf39692f --- /dev/null +++ b/src/common/utils/ai/modelAvailability.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; + +import type { ProvidersConfigMap } from "@/common/orpc/types"; +import { isModelServableWithProvidersConfig } from "./modelAvailability"; + +const MODEL = "anthropic:claude-haiku-4-5"; + +function providers(entry: { isConfigured: boolean; isEnabled?: boolean }): ProvidersConfigMap { + return { anthropic: entry } as unknown as ProvidersConfigMap; +} + +describe("isModelServableWithProvidersConfig", () => { + test("serves a model whose direct provider is configured", () => { + expect( + isModelServableWithProvidersConfig({ + canonicalModel: MODEL, + routePriority: ["direct"], + providersConfig: providers({ isConfigured: true }), + }) + ).toBe(true); + }); + + test("rejects a model whose provider is not configured", () => { + expect( + isModelServableWithProvidersConfig({ + canonicalModel: MODEL, + routePriority: ["direct"], + providersConfig: providers({ isConfigured: false }), + }) + ).toBe(false); + }); + + test("a disabled provider does not count as configured", () => { + expect( + isModelServableWithProvidersConfig({ + canonicalModel: MODEL, + routePriority: ["direct"], + providersConfig: providers({ isConfigured: true, isEnabled: false }), + }) + ).toBe(false); + }); + + test("a configured provider outside the route priority list cannot serve", () => { + // Availability must honor route priority (matching a real send), not just + // "some provider somewhere is configured". + expect( + isModelServableWithProvidersConfig({ + canonicalModel: MODEL, + routePriority: [], + providersConfig: providers({ isConfigured: true }), + }) + ).toBe(false); + }); + + test("route priority defaults to direct when omitted", () => { + expect( + isModelServableWithProvidersConfig({ + canonicalModel: MODEL, + providersConfig: providers({ isConfigured: true }), + }) + ).toBe(true); + }); + + describe("direct OpenAI credential gating", () => { + function openaiProviders(entry: Record): ProvidersConfigMap { + return { openai: { isConfigured: true, ...entry } } as unknown as ProvidersConfigMap; + } + + test("an OAuth-only config cannot serve an OAuth-ineligible model directly", () => { + // gpt-5.5-pro is not in the Codex OAuth allowed set: with no API key the + // factory would reject the direct route (api_key_not_found), so + // availability must not claim it. + expect( + isModelServableWithProvidersConfig({ + canonicalModel: "openai:gpt-5.5-pro", + routePriority: ["direct"], + providersConfig: openaiProviders({ codexOauthSet: true }), + }) + ).toBe(false); + }); + + test("an OAuth-only config serves OAuth-allowed models directly", () => { + expect( + isModelServableWithProvidersConfig({ + canonicalModel: "openai:gpt-5.5", + routePriority: ["direct"], + providersConfig: openaiProviders({ codexOauthSet: true }), + }) + ).toBe(true); + }); + + test("an API key serves OAuth-ineligible models directly", () => { + expect( + isModelServableWithProvidersConfig({ + canonicalModel: "openai:gpt-5.5-pro", + routePriority: ["direct"], + providersConfig: openaiProviders({ apiKeySet: true }), + }) + ).toBe(true); + }); + + test("a custom openai-compatible provider shadowing the openai id is exempt from OAuth gating", () => { + // Keyless custom endpoints authenticate on their own terms; built-in + // OpenAI credential rules must not mark their models unavailable. + expect( + isModelServableWithProvidersConfig({ + canonicalModel: "openai:gpt-5.5-pro", + routePriority: ["direct"], + providersConfig: openaiProviders({ providerType: "openai-compatible" }), + }) + ).toBe(true); + }); + + test("an API key serves OAuth-preferred models too (factory falls back to the key)", () => { + expect( + isModelServableWithProvidersConfig({ + canonicalModel: "openai:gpt-5.3-codex-spark", + routePriority: ["direct"], + providersConfig: openaiProviders({ apiKeySet: true }), + }) + ).toBe(true); + }); + }); +}); diff --git a/src/common/utils/ai/modelAvailability.ts b/src/common/utils/ai/modelAvailability.ts new file mode 100644 index 0000000000..e2b417d512 --- /dev/null +++ b/src/common/utils/ai/modelAvailability.ts @@ -0,0 +1,82 @@ +import type { ProvidersConfigMap } from "@/common/orpc/types"; +import { isModelAvailable } from "@/common/routing"; +import { isGatewayModelAccessibleFromAuthoritativeCatalog } from "@/common/utils/providers/gatewayModelCatalog"; +import { canDirectOpenAIServeModel } from "@/common/utils/providers/codexOauthRouting"; + +/** + * Provider-configured predicate shared by the routing UI (useRouting) and the + * send-path availability check below. One definition, two consumers — the + * Settings picker and the skill-routing verdict must never disagree. + */ +export function isRouteProviderConfigured( + providersConfig: ProvidersConfigMap, + provider: string +): boolean { + return ( + providersConfig[provider]?.isConfigured === true && + providersConfig[provider]?.isEnabled !== false + ); +} + +/** Gateway-catalog accessibility predicate; see isRouteProviderConfigured. */ +export function isRouteGatewayModelAccessible( + providersConfig: ProvidersConfigMap, + gateway: string, + modelId: string +): boolean { + return isGatewayModelAccessibleFromAuthoritativeCatalog( + gateway, + modelId, + providersConfig[gateway]?.models, + providersConfig[gateway]?.discoveredModels, + providersConfig[gateway]?.removedModels + ); +} + +/** + * Can the current routing state actually serve this model? + * + * Wraps the routing layer's isModelAvailable with the same provider + * predicates the Settings UI uses (useRouting): a provider counts as + * configured when `isConfigured` is set and it is not disabled, and gateway + * accessibility consults the authoritative model catalog. Route priority and + * per-model overrides are honored, so a gateway that is configured but not in + * the priority list does not count — matching what a send would really do. + * + * Callers that cannot obtain a ProvidersConfigMap (degraded state, minimal + * test mocks) must skip the check rather than pass an empty map: + * "cannot determine" is not "unavailable". + * + * Known one-directional gap: enforced-policy model gating (policyService + * isModelAllowed, applied inside the node-side gateway checker) is not + * consulted here, so this can over-report availability for policy-blocked + * gateway models — the send then fails with the provider's own error rather + * than the actionable class message. It can never spuriously block. + */ +export function isModelServableWithProvidersConfig(args: { + canonicalModel: string; + routePriority?: string[]; + routeOverrides?: Record; + providersConfig: ProvidersConfigMap; +}): boolean { + const providersConfig = args.providersConfig; + return isModelAvailable( + args.canonicalModel, + args.routePriority ?? ["direct"], + args.routeOverrides ?? {}, + (provider) => { + if (!isRouteProviderConfigured(providersConfig, provider)) { + return false; + } + // OpenAI's isConfigured can mean Codex-OAuth-only credentials, which + // serve only the OAuth-allowed model set — a direct route the factory + // would reject (api_key_not_found) must not win over a later gateway or + // suppress the actionable class error. + if (provider === "openai") { + return canDirectOpenAIServeModel(args.canonicalModel, providersConfig); + } + return true; + }, + (gateway, modelId) => isRouteGatewayModelAccessible(providersConfig, gateway, modelId) + ); +} diff --git a/src/common/utils/ai/skillModelClasses.test.ts b/src/common/utils/ai/skillModelClasses.test.ts new file mode 100644 index 0000000000..680be38741 --- /dev/null +++ b/src/common/utils/ai/skillModelClasses.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "bun:test"; + +import { KNOWN_MODELS } from "@/common/constants/knownModels"; +import { + buildModelClassValue, + parseModelClassValue, + resolveSkillModelClassBinding, + splitModelClassValue, +} from "./skillModelClasses"; + +describe("parseModelClassValue", () => { + test("resolves a bare alias without a thinking level", () => { + expect(parseModelClassValue("haiku")).toEqual({ model: KNOWN_MODELS.HAIKU.id }); + }); + + test("resolves alias + numeric thinking (deferred as an index)", () => { + expect(parseModelClassValue("haiku+0")).toEqual({ + model: KNOWN_MODELS.HAIKU.id, + thinkingLevel: 0, + }); + }); + + test("resolves alias + named thinking", () => { + expect(parseModelClassValue("sonnet+high")).toEqual({ + model: KNOWN_MODELS.SONNET.id, + thinkingLevel: "high", + }); + }); + + test("accepts full provider:model ids (unlike the composer one-shot parser)", () => { + expect(parseModelClassValue("anthropic:claude-fable-5+max")).toEqual({ + model: KNOWN_MODELS.FABLE.id, + thinkingLevel: "max", + }); + }); + + test("rejects unknown model input", () => { + expect(parseModelClassValue("not-a-model")).toBeNull(); + expect(parseModelClassValue("")).toBeNull(); + }); + + test("rejects an invalid thinking suffix instead of ignoring it", () => { + expect(parseModelClassValue("haiku+bogus")).toBeNull(); + expect(parseModelClassValue("haiku+")).toBeNull(); + }); +}); + +describe("splitModelClassValue / buildModelClassValue", () => { + test("round-trips a raw thinking suffix so numeric levels survive model changes", () => { + // "+0" is model-relative user intent ("lowest"): swapping the model in an + // editor must not concretize it to the old model's floor. + const { thinkingSuffix } = splitModelClassValue("haiku+0"); + expect(thinkingSuffix).toBe("0"); + expect(buildModelClassValue(KNOWN_MODELS.SONNET.id, thinkingSuffix)).toBe( + `${KNOWN_MODELS.SONNET.id}+0` + ); + }); + + test("handles suffix-less values", () => { + expect(splitModelClassValue("sonnet")).toEqual({ modelPart: "sonnet", thinkingSuffix: null }); + expect(buildModelClassValue("sonnet", null)).toBe("sonnet"); + }); +}); + +describe("resolveSkillModelClassBinding", () => { + const modelClasses = { + small: "haiku+0", + large: "anthropic:claude-fable-5+max", + }; + + test("binds via frontmatter metadata and resolves numeric thinking to a concrete level", () => { + const binding = resolveSkillModelClassBinding({ + skillName: "done", + frontmatterMetadata: { "model-class": "small" }, + modelClasses, + }); + // Haiku's lowest allowed thinking level is "off": index 0 must resolve + // model-relatively, not to the literal level "0". + expect(binding).toEqual({ + status: "resolved", + className: "small", + model: KNOWN_MODELS.HAIKU.id, + thinkingLevel: "off", + }); + }); + + test("config routing table wins over frontmatter metadata", () => { + const binding = resolveSkillModelClassBinding({ + skillName: "done", + frontmatterMetadata: { "model-class": "small" }, + modelClasses, + skillModelClasses: { done: "large" }, + }); + expect(binding).toMatchObject({ + status: "resolved", + model: KNOWN_MODELS.FABLE.id, + thinkingLevel: "max", + }); + }); + + test("table entries for other skills do not shadow the metadata binding", () => { + const binding = resolveSkillModelClassBinding({ + skillName: "done", + frontmatterMetadata: { "model-class": "small" }, + modelClasses, + skillModelClasses: { review: "large" }, + }); + expect(binding).toMatchObject({ status: "resolved", model: KNOWN_MODELS.HAIKU.id }); + }); + + test("frontmatter bindings to an undefined class stay inert (skills the user does not own)", () => { + expect( + resolveSkillModelClassBinding({ + skillName: "done", + frontmatterMetadata: { "model-class": "tiny" }, + modelClasses, + }) + ).toEqual({ status: "unbound" }); + }); + + test("a dangling table binding reports unknown-class (user's own routing intent)", () => { + expect( + resolveSkillModelClassBinding({ + skillName: "done", + modelClasses, + skillModelClasses: { done: "tiny" }, + }) + ).toEqual({ status: "unknown-class", className: "tiny" }); + }); + + test("reports an invalid class value instead of swallowing it", () => { + expect( + resolveSkillModelClassBinding({ + skillName: "done", + frontmatterMetadata: { "model-class": "small" }, + modelClasses: { small: "not-a-model" }, + }) + ).toEqual({ status: "invalid-value", className: "small", value: "not-a-model" }); + }); + + test("skills without any binding are unbound", () => { + expect(resolveSkillModelClassBinding({ skillName: "done", modelClasses })).toEqual({ + status: "unbound", + }); + }); + + test("frontmatter bindings are inert until the user configures model classes", () => { + // A skill shipping `metadata: model-class` must not error for users who + // never opted into model classes. + expect( + resolveSkillModelClassBinding({ + skillName: "done", + frontmatterMetadata: { "model-class": "small" }, + }) + ).toEqual({ status: "unbound" }); + }); + + test("a config-table binding is explicit intent and errors even without a class map", () => { + expect( + resolveSkillModelClassBinding({ + skillName: "done", + skillModelClasses: { done: "small" }, + }) + ).toEqual({ status: "unknown-class", className: "small" }); + }); + + test("a class without a thinking suffix overrides only the model", () => { + const binding = resolveSkillModelClassBinding({ + skillName: "done", + frontmatterMetadata: { "model-class": "medium" }, + modelClasses: { medium: "sonnet" }, + }); + expect(binding).toEqual({ + status: "resolved", + className: "medium", + model: KNOWN_MODELS.SONNET.id, + }); + }); +}); diff --git a/src/common/utils/ai/skillModelClasses.ts b/src/common/utils/ai/skillModelClasses.ts new file mode 100644 index 0000000000..897d480b07 --- /dev/null +++ b/src/common/utils/ai/skillModelClasses.ts @@ -0,0 +1,191 @@ +import type { ProvidersConfigMap } from "@/common/orpc/types"; +import { + parseThinkingInput, + type ParsedThinkingInput, + type ThinkingLevel, +} from "@/common/types/thinking"; +import { normalizeModelInput } from "@/common/utils/ai/normalizeModelInput"; +import { resolveThinkingInput } from "@/common/utils/thinking/policy"; + +/** + * Skill frontmatter `metadata` key naming the model class a skill prefers to + * run on (e.g. `metadata: { model-class: small }`). The spec-standard metadata + * map is used instead of a new frontmatter field so skills stay portable: + * other agent tools ignore unknown metadata entries, and mux already parses + * and preserves the map. + */ +export const SKILL_MODEL_CLASS_METADATA_KEY = "model-class"; + +export interface ModelClassTarget { + model: string; + /** Deferred: numeric indices are model-relative until resolved. */ + thinkingLevel?: ParsedThinkingInput; +} + +/** + * Parse a `modelClasses` config value. Values use the one-shot override + * syntax: a model alias or full "provider:model" id with an optional + * `+thinking` suffix ("haiku+0", "sonnet+high", "anthropic:claude-fable-5+max"). + * + * Unlike the composer's one-shot key parser (which only accepts known aliases + * so unknown slash commands aren't swallowed), full model ids are accepted + * here — config values are explicit user intent with no shadowing risk. + * + * Returns null when the model or thinking part is invalid (callers fail open). + */ +export function parseModelClassValue(value: string): ModelClassTarget | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const plusIndex = trimmed.indexOf("+"); + const modelPart = plusIndex === -1 ? trimmed : trimmed.slice(0, plusIndex); + const thinkingPart = plusIndex === -1 ? null : trimmed.slice(plusIndex + 1); + + const normalized = normalizeModelInput(modelPart); + if (normalized.model == null) { + return null; + } + + if (thinkingPart == null) { + return { model: normalized.model }; + } + + const thinkingLevel = parseThinkingInput(thinkingPart); + if (thinkingLevel == null) { + return null; + } + + return { model: normalized.model, thinkingLevel }; +} + +/** + * Split a class value into its model part and raw thinking suffix. The raw + * suffix is what follows the first "+" verbatim: editors preserve it across + * model changes so a model-relative numeric level ("+0" = lowest allowed) + * keeps its meaning on the new model. + */ +export function splitModelClassValue(value: string): { + modelPart: string; + thinkingSuffix: string | null; +} { + const plusIndex = value.indexOf("+"); + if (plusIndex === -1) { + return { modelPart: value, thinkingSuffix: null }; + } + return { modelPart: value.slice(0, plusIndex), thinkingSuffix: value.slice(plusIndex + 1) }; +} + +/** Inverse of splitModelClassValue: build a `model[+thinking]` class value. */ +export function buildModelClassValue(model: string, thinkingSuffix: string | null): string { + return thinkingSuffix ? `${model}+${thinkingSuffix}` : model; +} + +/** + * Class names surfaced as fixed slots in the Settings → Models editor. The + * config map accepts arbitrary names (hand-edited custom classes are preserved + * and keep routing), but skills are portable across machines only when they + * bind to this shared vocabulary. + */ +export const CANONICAL_MODEL_CLASSES = ["large", "medium", "small"] as const; + +export type SkillModelClassBinding = + | { status: "unbound" } + | { status: "unknown-class"; className: string } + | { status: "invalid-value"; className: string; value: string } + | { status: "resolved"; className: string; model: string; thinkingLevel?: ThinkingLevel }; + +/** + * Resolve the model-class binding for a slash-invoked skill. + * + * Class-binding precedence: the config-side routing table + * (`skillModelClasses[skillName]`) wins over the skill's own frontmatter + * `metadata["model-class"]` — local config is explicit user intent and works + * for skills the user does not own. + * + * Broken bindings are reported, not swallowed: a bound skill whose class is + * missing or malformed returns a distinct status so callers can raise an + * actionable error instead of silently running on an unintended (possibly + * expensive) model. One deliberate exception: frontmatter bindings are inert + * while the user has no `modelClasses` configured at all — skills shipping + * `metadata: model-class` must not break for users who never opted into model + * classes. A config-table binding is explicit local intent and always counts. + */ +export function resolveSkillModelClassBinding(args: { + skillName: string; + frontmatterMetadata?: Record; + modelClasses?: Record; + skillModelClasses?: Record; + providersConfig?: ProvidersConfigMap | null; +}): SkillModelClassBinding { + const tableClass = args.skillModelClasses?.[args.skillName]; + const boundViaTable = typeof tableClass === "string" && tableClass.trim().length > 0; + const rawClass = boundViaTable + ? tableClass + : args.frontmatterMetadata?.[SKILL_MODEL_CLASS_METADATA_KEY]; + const className = typeof rawClass === "string" ? rawClass.trim() : ""; + if (!className) { + return { status: "unbound" }; + } + + const modelClasses = args.modelClasses ?? {}; + const classValue = modelClasses[className]; + if (typeof classValue !== "string") { + // A frontmatter binding to a class the user never defined stays inert: + // skills the user does not own must not start failing sends just because + // some other class got configured (partial configuration is the normal + // state of the three-slot editor). A config-table binding is the user's + // own explicit routing intent, so a dangling table entry errors loudly. + // Bindings to a class that EXISTS but is broken (invalid value, + // unavailable model) always error — that is the churn signal this + // feature exists to surface. + if (!boundViaTable) { + return { status: "unbound" }; + } + return { status: "unknown-class", className }; + } + + const target = parseModelClassValue(classValue); + if (!target) { + return { status: "invalid-value", className, value: classValue }; + } + + // Numeric thinking indices are model-relative; resolve to a concrete level + // now. Downstream enforceThinkingPolicy still clamps (min-thinking floors). + const thinkingLevel = + target.thinkingLevel != null + ? resolveThinkingInput(target.thinkingLevel, target.model, args.providersConfig) + : undefined; + + return { + status: "resolved", + className, + model: target.model, + ...(thinkingLevel != null ? { thinkingLevel } : {}), + }; +} + +export type SkillModelClassRoutingProblem = + | { kind: "unknown-class"; skillName: string; className: string } + | { kind: "invalid-value"; skillName: string; className: string; value: string } + | { kind: "model-unavailable"; skillName: string; className: string; model: string }; + +/** + * User-facing message for a broken skill model-class binding. The copy always + * names the fix location and the one-shot bypass so a stale mapping never + * strands the user. + */ +export function describeSkillModelClassRoutingProblem( + problem: SkillModelClassRoutingProblem +): string { + const fixHint = `Update it in Settings → Models → Model Classes, or bypass routing with a one-shot override (e.g. "/sonnet /${problem.skillName}").`; + switch (problem.kind) { + case "unknown-class": + return `Skill "${problem.skillName}" is bound to model class "${problem.className}", but no class with that name is configured. ${fixHint}`; + case "invalid-value": + return `Model class "${problem.className}" (used by skill "${problem.skillName}") has an invalid value "${problem.value}". ${fixHint}`; + case "model-unavailable": + return `Model class "${problem.className}" (used by skill "${problem.skillName}") maps to "${problem.model}", but no configured provider route can serve it. ${fixHint}`; + } +} diff --git a/src/common/utils/providers/codexOauthRouting.ts b/src/common/utils/providers/codexOauthRouting.ts index e961342bee..b062987d43 100644 --- a/src/common/utils/providers/codexOauthRouting.ts +++ b/src/common/utils/providers/codexOauthRouting.ts @@ -10,6 +10,7 @@ */ import { isCodexOauthAllowedModel, isCodexOauthRequiredModel } from "@/common/constants/codexOAuth"; +import { isCustomOpenAICompatibleProviderConfig } from "@/common/utils/providers/customProviders"; import type { ProvidersConfigMap } from "@/common/orpc/types"; function asRecord(value: unknown): Record | null { @@ -66,6 +67,35 @@ export function hasOpenAIApiKey(config: unknown): boolean { * required models always route OAuth; otherwise OAuth wins when no API key is * configured or when `codexOauthDefaultAuth` prefers OAuth over a present key. */ +/** + * Can a DIRECT OpenAI route serve this model with the credentials on hand? + * + * `isConfigured` alone over-reports: a Codex-OAuth-only config serves only the + * OAuth-allowed model set. Mirrors providerModelFactory's credential outcome — + * an API key always attempts (OAuth-required models fall back to the key and + * let the API decide), while stored tokens without a key serve only allowed + * models — so availability checks can't claim a direct route the factory + * would reject with api_key_not_found. + */ +export function canDirectOpenAIServeModel( + model: string, + providersConfig: ProvidersConfigMap | null | undefined +): boolean { + const openAIConfig = providersConfig?.openai; + // A custom openai-compatible provider shadowing the built-in "openai" id is + // direct-only and authenticates against its own endpoint (key optional): + // built-in OpenAI credential rules don't apply to it. + if (isCustomOpenAICompatibleProviderConfig(openAIConfig)) { + return true; + } + if (hasOpenAIApiKey(openAIConfig)) { + return true; + } + return ( + hasCodexOauthTokens(openAIConfig) && isCodexOauthAllowedModel(model, providersConfig ?? null) + ); +} + export function wouldRouteOpenAIThroughCodexOauth( model: string, providersConfig: ProvidersConfigMap | null | undefined diff --git a/src/node/config.modelClasses.test.ts b/src/node/config.modelClasses.test.ts new file mode 100644 index 0000000000..c423148586 --- /dev/null +++ b/src/node/config.modelClasses.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { Config } from "@/node/config"; + +describe("Config model classes persistence", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-model-classes-")); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("round-trips modelClasses and skillModelClasses through editConfig saves", async () => { + const config = new Config(tempDir); + await config.editConfig((cfg) => ({ + ...cfg, + modelClasses: { small: "haiku+0", large: "anthropic:claude-fable-5+max" }, + skillModelClasses: { done: "small" }, + })); + + // A fresh instance re-reads from disk: the fields must survive the + // whitelist-based saveConfig serialization. + const reloaded = new Config(tempDir).loadConfigOrDefault(); + expect(reloaded.modelClasses).toEqual({ + small: "haiku+0", + large: "anthropic:claude-fable-5+max", + }); + expect(reloaded.skillModelClasses).toEqual({ done: "small" }); + + // An unrelated edit (another full save cycle) must not strip them. + const second = new Config(tempDir); + await second.editConfig((cfg) => ({ ...cfg, defaultModel: "anthropic:claude-opus-5" })); + const reloadedAgain = new Config(tempDir).loadConfigOrDefault(); + expect(reloadedAgain.modelClasses?.small).toBe("haiku+0"); + expect(reloadedAgain.skillModelClasses?.done).toBe("small"); + }); + + it("drops non-string entries on load instead of failing (self-healing)", async () => { + await fs.writeFile( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + modelClasses: { small: "haiku+0", bad: 42 }, + skillModelClasses: 7, + }) + ); + + const loaded = new Config(tempDir).loadConfigOrDefault(); + expect(loaded.modelClasses).toEqual({ small: "haiku+0" }); + expect(loaded.skillModelClasses).toBeUndefined(); + }); +}); diff --git a/src/node/config.ts b/src/node/config.ts index 89ff57b581..42ad794e7d 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -1243,6 +1243,12 @@ export class Config { const modelFallbacks = normalizeModelFallbacks(parsed.modelFallbacks); + // Lenient on read: malformed entries never break config load. Values + // are judged at send time by resolveSkillModelClassBinding — a bound + // class with a bad value fails that send with an actionable error. + const modelClasses = parseOptionalStringRecord(parsed.modelClasses); + const skillModelClasses = parseOptionalStringRecord(parsed.skillModelClasses); + const defaultModel = normalizeOptionalModelString(parsed.defaultModel); const advisorModelString = parseOptionalNonEmptyString(parsed.advisorModelString); const advisorThinkingLevel = parseOptionalThinkingLevel(parsed.advisorThinkingLevel); @@ -1389,6 +1395,8 @@ export class Config { routeOverrides, minThinkingLevelByModel, modelFallbacks, + modelClasses, + skillModelClasses, defaultModel, advisorModelString, advisorThinkingLevel, @@ -1583,6 +1591,16 @@ export class Config { data.modelFallbacks = modelFallbacks; } + const modelClasses = parseOptionalStringRecord(config.modelClasses); + if (modelClasses !== undefined) { + data.modelClasses = modelClasses; + } + + const skillModelClasses = parseOptionalStringRecord(config.skillModelClasses); + if (skillModelClasses !== undefined) { + data.skillModelClasses = skillModelClasses; + } + const apiServerBindHost = parseOptionalNonEmptyString(config.apiServerBindHost); if (apiServerBindHost) { data.apiServerBindHost = apiServerBindHost; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index a94fcada3d..73061c6510 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1063,6 +1063,8 @@ export const router = (authToken?: string) => { routeOverrides: config.routeOverrides, minThinkingLevelByModel: config.minThinkingLevelByModel, modelFallbacks: config.modelFallbacks, + modelClasses: config.modelClasses, + skillModelClasses: config.skillModelClasses, defaultModel: config.defaultModel, advisorModelString: config.advisorModelString ?? null, advisorThinkingLevel: config.advisorThinkingLevel ?? null, @@ -1237,6 +1239,21 @@ export const router = (authToken?: string) => { modelFallbacks: Object.keys(sanitized).length > 0 ? sanitized : undefined, })); }), + updateModelClasses: t + .input(schemas.config.updateModelClasses.input) + .output(schemas.config.updateModelClasses.output) + .handler(async ({ context, input }) => { + // Full-map replacement, stored verbatim: entries this build cannot + // parse (hand-edited custom models, future syntax) must survive + // Settings edits. Broken values already fail loudly at send time + // and are flagged inline by the editor — silently dropping them + // here would delete user config as a side effect of unrelated edits. + await context.config.editConfig((config) => ({ + ...config, + modelClasses: + Object.keys(input.modelClasses).length > 0 ? input.modelClasses : undefined, + })); + }), updateModelPreferences: t .input(schemas.config.updateModelPreferences.input) .output(schemas.config.updateModelPreferences.output) @@ -4432,7 +4449,18 @@ export const router = (authToken?: string) => { return { success: false, error: result.error }; } - return { success: true, data: {} }; + return { + success: true, + data: + result.data?.routedModel != null + ? { + routedModel: result.data.routedModel, + ...(result.data.routedThinkingLevel != null + ? { routedThinkingLevel: result.data.routedThinkingLevel } + : {}), + } + : {}, + }; }), answerAskUserQuestion: t .input(schemas.workspace.answerAskUserQuestion.input) diff --git a/src/node/services/agentSession.skillModelRouting.test.ts b/src/node/services/agentSession.skillModelRouting.test.ts new file mode 100644 index 0000000000..06656e7933 --- /dev/null +++ b/src/node/services/agentSession.skillModelRouting.test.ts @@ -0,0 +1,376 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { KNOWN_MODELS } from "@/common/constants/knownModels"; +import { Ok } from "@/common/types/result"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { Config } from "@/node/config"; +import type { AIService, StreamMessageOptions } from "@/node/services/aiService"; + +import { createAgentSessionHarness } from "./agentSession.testHarness"; + +const USER_MODEL = "anthropic:claude-fable-5"; + +describe("AgentSession.sendMessage (per-skill model routing)", () => { + let historyCleanup: (() => Promise) | undefined; + const tempDirs: string[] = []; + const sessions: Array<{ dispose: () => void }> = []; + afterEach(async () => { + // Safety net: a failed assertion above a test's own dispose() must not + // leak a live session into the rest of the file, and temp skill trees + // must not accumulate in the OS temp dir. + for (const session of sessions.splice(0)) { + try { + session.dispose(); + } catch { + // Already disposed by the test body. + } + } + await historyCleanup?.(); + for (const dir of tempDirs.splice(0)) { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + async function createWorkspaceWithSkill(args: { skillName: string; metadataYaml?: string }) { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mux-skill-routing-")); + tempDirs.push(tmp); + const skillDir = path.join(tmp, ".mux", "skills", args.skillName); + await fs.mkdir(skillDir, { recursive: true }); + const skillMarkdown = `---\nname: ${args.skillName}\ndescription: Test skill\n${args.metadataYaml ?? ""}---\n\nDo the thing.\n`; + await fs.writeFile(path.join(skillDir, "SKILL.md"), skillMarkdown, "utf-8"); + return tmp; + } + + async function createRoutingHarness(args: { + workspacePath: string; + configValues?: { + modelClasses?: Record; + skillModelClasses?: Record; + routePriority?: string[]; + }; + /** When provided, getProvidersConfigSafe sees this map (enables the availability check). */ + providersConfig?: Record; + }) { + const workspaceId = "ws-skill-routing"; + const workspaceMeta = { + id: workspaceId, + name: "ws", + projectName: "proj", + projectPath: args.workspacePath, + namedWorkspacePath: args.workspacePath, + runtimeConfig: { type: "local" }, + } as unknown as FrontendWorkspaceMetadata; + + const streamed: StreamMessageOptions[] = []; + const streamMessage = mock((opts: StreamMessageOptions) => { + streamed.push(opts); + return Promise.resolve(Ok(undefined)); + }); + + const config = { + srcDir: "/tmp", + getSessionDir: mock((_workspaceId: string) => "/tmp"), + loadConfigOrDefault: mock(() => ({ ...args.configValues })), + } as unknown as Config; + + const { session, cleanup } = await createAgentSessionHarness({ + workspaceId, + config, + aiServiceOverrides: { + getWorkspaceMetadata: mock((_id: string) => Promise.resolve(Ok(workspaceMeta))), + streamMessage: streamMessage as unknown as AIService["streamMessage"], + ...(args.providersConfig != null + ? { getProvidersConfig: mock(() => args.providersConfig) } + : {}), + } as unknown as Partial, + }); + historyCleanup = cleanup; + sessions.push(session); + return { session, streamed }; + } + + function skillSendOptions(overrides?: Record) { + return { + model: USER_MODEL, + agentId: "exec", + muxMetadata: { + type: "agent-skill", + rawCommand: "/done", + skillName: "done", + scope: "project", + }, + ...overrides, + }; + } + + it("streams a metadata-bound skill on its class model with resolved thinking", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: small\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { modelClasses: { small: "haiku+0" } }, + }); + + const result = await session.sendMessage("Use skill done", skillSendOptions()); + expect(result.success).toBe(true); + // The accepted-send payload reports the routed model and thinking so the + // frontend can attribute send telemetry to what actually streams. + expect(result.success && result.data?.routedModel).toBe(KNOWN_MODELS.HAIKU.id); + expect(result.success && result.data?.routedThinkingLevel).toBe("off"); + expect(streamed).toHaveLength(1); + expect(streamed[0].modelString).toBe(KNOWN_MODELS.HAIKU.id); + // "+0" is model-relative: haiku's lowest allowed level is "off". + expect(streamed[0].thinkingLevel).toBe("off"); + session.dispose(); + }); + + it("lets the config skillModelClasses table win over frontmatter metadata", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: small\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { + modelClasses: { small: "haiku+0", big: "anthropic:claude-opus-5+high" }, + skillModelClasses: { done: "big" }, + }, + }); + + const result = await session.sendMessage("Use skill done", skillSendOptions()); + expect(result.success).toBe(true); + expect(streamed[0].modelString).toBe(KNOWN_MODELS.OPUS.id); + expect(streamed[0].thinkingLevel).toBe("high"); + session.dispose(); + }); + + it("routes a table-bound skill that has no frontmatter metadata", async () => { + const workspacePath = await createWorkspaceWithSkill({ skillName: "done" }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { + modelClasses: { small: "haiku+0" }, + skillModelClasses: { done: "small" }, + }, + }); + + const result = await session.sendMessage("Use skill done", skillSendOptions()); + expect(result.success).toBe(true); + expect(streamed[0].modelString).toBe(KNOWN_MODELS.HAIKU.id); + session.dispose(); + }); + + it("never re-routes sends that carry an explicit model override (skipSkillModelRouting)", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: small\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { modelClasses: { small: "haiku+0" } }, + }); + + const result = await session.sendMessage( + "Use skill done", + skillSendOptions({ skipSkillModelRouting: true }) + ); + expect(result.success).toBe(true); + // No routing applied — the accepted-send payload must not name a model. + expect(result.success && result.data?.routedModel).toBeUndefined(); + expect(streamed[0].modelString).toBe(USER_MODEL); + expect(streamed[0].thinkingLevel).toBeUndefined(); + session.dispose(); + }); + + it("still routes sends that only skip settings persistence (thinking-only one-shots)", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: small\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { modelClasses: { small: "haiku+0" } }, + }); + + // "/+2 /done" sets skipAiSettingsPersistence (to protect preferences) with + // no model override — class routing must still apply to the model while + // the explicit thinking level wins over the class default. + const result = await session.sendMessage( + "Use skill done", + skillSendOptions({ skipAiSettingsPersistence: true, thinkingLevel: "medium" }) + ); + expect(result.success).toBe(true); + expect(streamed[0].modelString).toBe(KNOWN_MODELS.HAIKU.id); + expect(streamed[0].thinkingLevel).toBe("medium"); + // The payload reports the effective level even when the one-shot rode + // through unchanged — telemetry must see what the routed stream runs at. + expect(result.success && result.data?.routedThinkingLevel).toBe("medium"); + session.dispose(); + }); + + it("re-resolves a numeric one-shot thinking index against the routed model", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: small\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { modelClasses: { small: "haiku" } }, + }); + + // "/+0 /done" typed on a workspace model whose lowest allowed level is + // "medium": the frontend resolves thinkingLevel against the WORKSPACE + // ladder and passes the raw index alongside. The routed model's ladder + // differs (haiku's index 0 is "off"), so the re-resolved index — not the + // pre-resolved level — must win. + const result = await session.sendMessage( + "Use skill done", + skillSendOptions({ + skipAiSettingsPersistence: true, + thinkingLevel: "medium", + oneShotThinkingIndex: 0, + }) + ); + expect(result.success).toBe(true); + expect(streamed[0].modelString).toBe(KNOWN_MODELS.HAIKU.id); + expect(streamed[0].thinkingLevel).toBe("off"); + session.dispose(); + }); + + it("leaves frontmatter bindings to an undefined class inert (streams the caller's model)", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: tiny\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { modelClasses: { small: "haiku+0" } }, + }); + + // Skills the user does not own must not fail sends just because some + // other class is configured — an undefined frontmatter class is inert. + const result = await session.sendMessage("Use skill done", skillSendOptions()); + expect(result.success).toBe(true); + expect(streamed[0].modelString).toBe(USER_MODEL); + session.dispose(); + }); + + it("fails the send with an actionable error on a dangling table binding", async () => { + const workspacePath = await createWorkspaceWithSkill({ skillName: "done" }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + // The table is the user's own routing intent: naming a class that no + // longer exists must error loudly, not silently unroute. + configValues: { modelClasses: { small: "haiku+0" }, skillModelClasses: { done: "tiny" } }, + }); + + const result = await session.sendMessage("Use skill done", skillSendOptions()); + expect(result.success).toBe(false); + // The error must name the class so the user knows which mapping to fix. + const raw = !result.success && result.error.type === "unknown" ? result.error.raw : ""; + expect(raw).toContain('"tiny"'); + expect(streamed).toHaveLength(0); + session.dispose(); + }); + + it("honors frontmatter routing when a hand-edited table entry is blank", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: small\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + // A blank table value (hand-edit meaning "no override") must not + // suppress the frontmatter read and silently unroute the skill. + configValues: { modelClasses: { small: "haiku+0" }, skillModelClasses: { done: " " } }, + }); + + const result = await session.sendMessage("Use skill done", skillSendOptions()); + expect(result.success).toBe(true); + expect(streamed[0].modelString).toBe(KNOWN_MODELS.HAIKU.id); + session.dispose(); + }); + + it("fails the send with an actionable error when the class value is invalid", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: small\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + // Hand-edited config can hold values the strict-on-write path would + // have rejected; the send must not silently ignore them. + configValues: { modelClasses: { small: "not-a-model" } }, + }); + + const result = await session.sendMessage("Use skill done", skillSendOptions()); + expect(result.success).toBe(false); + const raw = !result.success && result.error.type === "unknown" ? result.error.raw : ""; + expect(raw).toContain('"small"'); + expect(streamed).toHaveLength(0); + session.dispose(); + }); + + it("fails the send when no configured route can serve the class model", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: small\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { modelClasses: { small: "haiku+0" }, routePriority: ["direct"] }, + providersConfig: { anthropic: { isConfigured: false } }, + }); + + const result = await session.sendMessage("Use skill done", skillSendOptions()); + expect(result.success).toBe(false); + const raw = !result.success && result.error.type === "unknown" ? result.error.raw : ""; + expect(raw).toContain(KNOWN_MODELS.HAIKU.id); + expect(streamed).toHaveLength(0); + session.dispose(); + }); + + it("routes normally when the class model has a configured route", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: small\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { modelClasses: { small: "haiku+0" }, routePriority: ["direct"] }, + providersConfig: { anthropic: { isConfigured: true, isEnabled: true } }, + }); + + const result = await session.sendMessage("Use skill done", skillSendOptions()); + expect(result.success).toBe(true); + expect(streamed[0].modelString).toBe(KNOWN_MODELS.HAIKU.id); + session.dispose(); + }); + + it("leaves non-skill sends untouched even with routing configured", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: small\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { + modelClasses: { small: "haiku+0" }, + skillModelClasses: { done: "small" }, + }, + }); + + const result = await session.sendMessage("plain message", { + model: USER_MODEL, + agentId: "exec", + }); + expect(result.success).toBe(true); + expect(streamed[0].modelString).toBe(USER_MODEL); + session.dispose(); + }); +}); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 8e42144661..3dc509e19f 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -32,7 +32,7 @@ import { SILENT_CONTINUATION_COMPLETION_SUMMARY_MAX_LENGTH, type GoalSyntheticMessageKind, } from "@/constants/goals"; -import type { SendMessageError } from "@/common/types/errors"; +import type { SendMessageAccepted, SendMessageError } from "@/common/types/errors"; import { AgentIdSchema, SkillNameSchema } from "@/common/orpc/schemas"; import { normalizeAgentId, resolvePersistedAgentIdCandidates } from "@/common/utils/agentIds"; import { @@ -62,6 +62,7 @@ import { enforceThinkingPolicy, lookupMinThinkingLevelOverride, resolveMinimumThinkingLevel, + resolveThinkingInput, } from "@/common/utils/thinking/policy"; import type { ActiveTurnThinkingOverride } from "@/node/services/thinkingOverride"; import { @@ -115,6 +116,8 @@ import { } from "@/common/utils/messages/extractEditedFiles"; import { buildCompactionMessageText } from "@/common/utils/compaction/compactionPrompt"; import type { AutoCompactionUsageState } from "@/common/utils/compaction/autoCompactionCheck"; +import { ROUTED_SEND_COMPACTION_HEADROOM_PERCENT } from "@/common/constants/ui"; +import { getEffectiveContextLimit } from "@/common/utils/compaction/contextLimit"; import { getModelCapabilitiesResolved } from "@/common/utils/ai/modelCapabilities"; import { getExplicitGatewayPrefix, @@ -131,6 +134,11 @@ import { import { createDisplayUsage } from "@/common/utils/tokens/displayUsage"; import { readAgentSkill } from "@/node/services/agentSkills/agentSkillsService"; import { resolveSkillStorageContext } from "@/node/services/agentSkills/skillStorageContext"; +import { + describeSkillModelClassRoutingProblem, + resolveSkillModelClassBinding, +} from "@/common/utils/ai/skillModelClasses"; +import { isModelServableWithProvidersConfig } from "@/common/utils/ai/modelAvailability"; import { createLoadedSkillSnapshot, extractLoadedSkillSnapshotsFromMessages, @@ -189,6 +197,14 @@ interface AutoRetryResumeRequest { options: SendMessageOptions; agentInitiated?: boolean; goalKind?: GoalSyntheticMessageKind; + /** + * Pre-skill-routing options for a routed turn (see + * activeStreamContext.compactionBaseOptions). A same-session retry must keep + * the routed compaction policy — without this, the retried stream would + * force-compact at the workspace threshold against the routed window and + * summarize on the wrong model. + */ + compactionBaseOptions?: SendMessageOptions; } function stripGoalInterventionPolicy(options: SendMessageOptions): SendMessageOptions { @@ -645,6 +661,14 @@ export class AgentSession { providersConfig: ProvidersConfigMap | null; goalKind?: GoalSyntheticMessageKind; workspaceTurnMetadata?: Extract; + /** + * Pre-skill-routing options for compaction requests spawned off this + * stream. A turn routed to a small class model must never compact on that + * model — the compaction model has to fit the full uncompacted history — + * so both the on-send and mid-stream compaction sites build their request + * from these options when present. + */ + compactionBaseOptions?: SendMessageOptions; }; private activeCompactionRequest?: { @@ -1008,7 +1032,8 @@ export class AgentSession { private setAutoRetryResumeState( options: SendMessageOptions | undefined, agentInitiated?: boolean, - goalKind?: GoalSyntheticMessageKind + goalKind?: GoalSyntheticMessageKind, + compactionBaseOptions?: SendMessageOptions ): void { if (!options) { this.lastAutoRetryResumeRequest = undefined; @@ -1019,6 +1044,7 @@ export class AgentSession { options, ...(agentInitiated === true ? { agentInitiated: true } : {}), ...(goalKind != null ? { goalKind } : {}), + ...(compactionBaseOptions != null ? { compactionBaseOptions } : {}), }; } @@ -1046,6 +1072,7 @@ export class AgentSession { const result = await this.resumeStream(request.options, { agentInitiated: request.agentInitiated === true ? true : undefined, goalKind: request.goalKind, + compactionBaseOptions: request.compactionBaseOptions, }); if (result.success) { if (!result.data.started) { @@ -1606,18 +1633,26 @@ export class AgentSession { const persistedModel = this.normalizeStartupModel(persistedRetrySendOptions?.model); const assistantModel = this.normalizeStartupModel(lastAssistantMessage?.metadata?.model); const agentSettingsModel = this.normalizeStartupModel(agentSettings?.model); - const baseModel = isChildTaskWorkspace - ? (agentSettingsModel ?? persistedModel ?? assistantModel ?? DEFAULT_MODEL) - : (persistedModel ?? assistantModel ?? agentSettingsModel ?? DEFAULT_MODEL); + // A retry row carrying routed compaction context recorded the CLASS model + // the turn actually streamed on. That persisted model must win even in + // child task workspaces (whose creation-time settings normally take + // precedence) — resuming on the workspace model while restoring a routed + // compaction policy would mismatch both. Agent identity stays the child's. + const isRoutedRetryRow = persistedRetrySendOptions?.compactionBaseOptions != null; + const baseModel = + isChildTaskWorkspace && !isRoutedRetryRow + ? (agentSettingsModel ?? persistedModel ?? assistantModel ?? DEFAULT_MODEL) + : (persistedModel ?? assistantModel ?? agentSettingsModel ?? DEFAULT_MODEL); const persistedThinkingLevel = coerceThinkingLevel(persistedRetrySendOptions?.thinkingLevel); const assistantThinkingLevel = coerceThinkingLevel( lastAssistantMessage?.metadata?.thinkingLevel ); const agentSettingsThinkingLevel = coerceThinkingLevel(agentSettings?.thinkingLevel); - const baseThinkingLevel = isChildTaskWorkspace - ? (agentSettingsThinkingLevel ?? persistedThinkingLevel ?? assistantThinkingLevel) - : (persistedThinkingLevel ?? assistantThinkingLevel ?? agentSettingsThinkingLevel); + const baseThinkingLevel = + isChildTaskWorkspace && !isRoutedRetryRow + ? (agentSettingsThinkingLevel ?? persistedThinkingLevel ?? assistantThinkingLevel) + : (persistedThinkingLevel ?? assistantThinkingLevel ?? agentSettingsThinkingLevel); // Pro reasoning mode threads alongside thinkingLevel from the same sources // (assistant message metadata does not carry it), so startup retries do not @@ -1740,6 +1775,13 @@ export class AgentSession { retryRequest.agentInitiated = true; } + // Routed turns persist their pre-routing compaction context; restore it so + // the post-relaunch retry keeps the routed compaction policy instead of + // force-compacting at the workspace threshold against the routed window. + if (persistedRetrySendOptions?.compactionBaseOptions != null) { + retryRequest.compactionBaseOptions = persistedRetrySendOptions.compactionBaseOptions; + } + return retryRequest; } @@ -1884,8 +1926,11 @@ export class AgentSession { return "completed"; } - const { agentInitiated, goalKind, ...resumeOptions } = retryRequest; - this.setAutoRetryResumeState(resumeOptions, agentInitiated, goalKind); + // compactionBaseOptions is retry-state metadata, not a send option: it + // must feed the resume state's routed-compaction context, never ride + // inside the replayed SendMessageOptions themselves. + const { agentInitiated, goalKind, compactionBaseOptions, ...resumeOptions } = retryRequest; + this.setAutoRetryResumeState(resumeOptions, agentInitiated, goalKind, compactionBaseOptions); } // Disk reads above may race with user actions; retry once the current work settles @@ -2540,7 +2585,7 @@ export class AgentSession { cancelState?: { canceledBeforeAcceptance: boolean }; cancelSignal?: AbortSignal; } - ): Promise> { + ): Promise> { this.assertNotDisposed("sendMessage"); assert(typeof message === "string", "sendMessage requires a string message"); @@ -2624,16 +2669,51 @@ export class AgentSession { // PRRT_kwDOPxxmWM5_s-jo). For synthetic sends (compaction, goal // continuation, etc.) the user did not type the message, so we just // return Err and let the synthetic caller log/handle it. + // Resolve per-skill model routing before any gate or mutation below: the + // pricing gate and PDF preflight must judge the model that will actually + // stream, and a broken class binding must reject the send BEFORE the edit + // path truncates history (see the invariant comment on the edit branch). + // Mirroring the pricing gate: a manual send rejected here is persisted and + // surfaced as a stream-error — a bare Err would let sendQueuedMessages() + // drop the user's queued input with no visible feedback. + const typedMuxMetadata = options?.muxMetadata as MuxMessageMetadata | undefined; + const skillModelOverride = options + ? await this.resolveSkillModelClassOverride(typedMuxMetadata, options) + : null; + if (await cancelBeforeAcceptance()) { + return Ok(undefined); + } + if (skillModelOverride?.kind === "config-error") { + const routingError = createUnknownSendMessageError(skillModelOverride.message); + // Preservation exists for dequeued sends whose composer already cleared — + // a rejected EDIT must not append the edited text as a new tail turn + // (the original message is untouched and the browser restores the draft). + if (isManualUserMessage && options?.editMessageId == null) { + const persisted = await this.preserveRejectedManualSend(message, options, routingError); + if (persisted) { + await this.applyManualUserMessageGoalSafety({ policy: "pause" }); + } + } + return Err(routingError); + } + // The model every downstream gate must validate: the routed class model + // when routing applies, else the caller's model. + const effectiveModelForGates = skillModelOverride?.model ?? options?.model; + if (this.workspaceGoalService) { const pricingGate = await this.workspaceGoalService.assertPricedModelForBudgetedGoal( this.workspaceId, - options?.model + effectiveModelForGates ); if (await cancelBeforeAcceptance()) { return Ok(undefined); } if (!pricingGate.success) { - if (isManualUserMessage) { + // Like the class-routing and PDF gates: preservation is for dequeued + // sends whose composer already cleared — a rejected EDIT (now + // reachable here via routed skill edits) must not append the edited + // text as a new tail turn. + if (isManualUserMessage && options?.editMessageId == null) { const persisted = await this.preserveRejectedManualSend( message, options, @@ -2734,16 +2814,35 @@ export class AgentSession { (part) => normalizeMediaType(part.mediaType) === PDF_MEDIA_TYPE ); - if (pdfParts.length > 0) { + if (pdfParts.length > 0 && effectiveModelForGates != null) { + // Judge the routed class model when skill routing applies — the + // workspace model's PDF support is irrelevant to what will stream. const caps = getModelCapabilitiesResolved( - options.model, + effectiveModelForGates, this.aiService.getProvidersConfig() ); + // Rejections persist + surface like the pricing/routing gates: routable + // skill sends skip the browser PDF preflight and can arrive here from + // the queue drain, where a bare Err would silently discard the user's + // text and attachment (the composer already cleared on queue accept). + const rejectPdf = async ( + errorMessage: string + ): Promise> => { + const pdfError = createUnknownSendMessageError(errorMessage); + // See the class-routing gate above: preservation is for dequeued + // sends, never for rejected edits (which would duplicate the turn). + if (isManualUserMessage && options?.editMessageId == null) { + const persisted = await this.preserveRejectedManualSend(message, options, pdfError); + if (persisted) { + await this.applyManualUserMessageGoalSafety({ policy: "pause" }); + } + } + return Err(pdfError); + }; + if (caps && !caps.supportsPdfInput) { - return Err( - createUnknownSendMessageError(`Model ${options.model} does not support PDF input.`) - ); + return rejectPdf(`Model ${effectiveModelForGates} does not support PDF input.`); } if (caps?.maxPdfSizeMb !== undefined) { @@ -2753,10 +2852,8 @@ export class AgentSession { if (bytes !== null && bytes > maxBytes) { const actualMb = (bytes / (1024 * 1024)).toFixed(1); const label = part.filename ?? "PDF"; - return Err( - createUnknownSendMessageError( - `${label} is ${actualMb}MB, but ${options.model} allows up to ${caps.maxPdfSizeMb}MB per PDF.` - ) + return rejectPdf( + `${label} is ${actualMb}MB, but ${effectiveModelForGates} allows up to ${caps.maxPdfSizeMb}MB per PDF.` ); } } @@ -2886,8 +2983,7 @@ export class AgentSession { // toolPolicy is properly typed via Zod schema inference const typedToolPolicy = options?.toolPolicy; - // muxMetadata is z.any() in schema - cast to proper type - const typedMuxMetadata = options?.muxMetadata as MuxMessageMetadata | undefined; + // typedMuxMetadata was hoisted above the routing/pricing gates. const acpPromptId = normalizeAcpPromptId(options?.acpPromptId) ?? extractAcpPromptId(typedMuxMetadata); const delegatedToolNames = @@ -2906,6 +3002,111 @@ export class AgentSession { ...(delegatedToolNames != null ? { delegatedToolNames } : {}), }); + // Apply the per-skill routing override resolved at the top of sendMessage + // (before the gates and the edit branch). Applied before the user message + // is created so startup retries (retrySendOptions) replay the routed + // model, and before the compaction threshold check so context-limit math + // uses the model that will actually stream. preRoutingOptions feeds the + // compaction REQUEST below: a turn routed to a small model must never + // compact on that small model — the compaction model has to fit the full + // uncompacted history. + const preRoutingOptions = optionsForStream; + let muxMetadataForMessage = typedMuxMetadata; + let routedThinkingLevel: ThinkingLevel | undefined; + if (skillModelOverride != null) { + modelForStream = skillModelOverride.model; + // Numeric one-shot thinking is model-relative: the frontend resolved + // options.thinkingLevel against the workspace model before routing was + // known, so "/+0 /skill" must be re-resolved here to mean the ROUTED + // model's lowest level, not the workspace model's. + const reroutedOneShotThinking = + options.oneShotThinkingIndex != null + ? resolveThinkingInput( + options.oneShotThinkingIndex, + skillModelOverride.model, + this.getProvidersConfigSafe() + ) + : undefined; + // Precedence: explicit numeric one-shot (re-resolved above) > class + // thinking > ambient options. skipAiSettingsPersistence marks one-shot + // sends, so a named "/+high /skill" keeps the user's level rather than + // the class default. + routedThinkingLevel = + reroutedOneShotThinking ?? + (skillModelOverride.thinkingLevel != null && options.skipAiSettingsPersistence !== true + ? skillModelOverride.thinkingLevel + : undefined); + optionsForStream = { + ...optionsForStream, + model: skillModelOverride.model, + ...(routedThinkingLevel != null ? { thinkingLevel: routedThinkingLevel } : {}), + }; + // The persisted request metadata must advertise the model that will + // actually stream: the pending-turn label and downstream consumers read + // requestedModel from the user message. + if (muxMetadataForMessage != null) { + muxMetadataForMessage = { + ...muxMetadataForMessage, + requestedModel: skillModelOverride.model, + }; + } + } + + // Routed sends report the class model and the effective thinking level + // back to the caller so successful-send telemetry attributes the + // invocation to what actually streams. The level is whatever the stream + // will receive (class suffix, re-resolved numeric one-shot, or a named + // one-shot / ambient level riding through), clamped by the same per-model + // floor enforcement the stream applies — "/+off /skill" routed onto a + // floor-medium model reports medium, not off. + const sendAccepted: SendMessageAccepted | undefined = + skillModelOverride != null + ? { + routedModel: skillModelOverride.model, + ...(optionsForStream.thinkingLevel != null + ? { + routedThinkingLevel: this.enforceThinkingFloorsForModel( + skillModelOverride.model, + optionsForStream.thinkingLevel, + this.getProvidersConfigSafe() + ), + } + : {}), + } + : undefined; + + // Which options a routed turn's compaction (on-send or mid-stream forced) + // must run with: the compaction request has to read the FULL uncompacted + // history, so it needs whichever model has the larger usable window. + // Routing usually shrinks the window (the user's model wins), but a class + // can also route UP — repeated routed turns can then grow the history past + // the user's model, and summarizing on it would just context-error again. + const compactionBaseOptionsForRoutedTurn = ((): SendMessageOptions | undefined => { + if (skillModelOverride == null) { + return undefined; + } + const providersConfigForWindows = this.getProvidersConfigSafe(); + const userModel = preRoutingOptions.model; + if (userModel == null) { + return optionsForStream; + } + const userLimit = getEffectiveContextLimit( + userModel, + this.is1MContextEnabledForModel(userModel, preRoutingOptions, providersConfigForWindows), + providersConfigForWindows + ); + const routedLimit = getEffectiveContextLimit( + skillModelOverride.model, + this.is1MContextEnabledForModel( + skillModelOverride.model, + optionsForStream, + providersConfigForWindows + ), + providersConfigForWindows + ); + return (routedLimit ?? 0) > (userLimit ?? 0) ? optionsForStream : preRoutingOptions; + })(); + const userMessage = createMuxMessage( messageId, "user", @@ -2914,8 +3115,13 @@ export class AgentSession { timestamp: Date.now(), toolPolicy: typedToolPolicy, disableWorkspaceAgents: options?.disableWorkspaceAgents, - retrySendOptions: pickStartupRetrySendOptions(optionsForStream, agentInitiated, goalKind), - muxMetadata: typedMuxMetadata, // Pass through frontend metadata as black-box + retrySendOptions: pickStartupRetrySendOptions( + optionsForStream, + agentInitiated, + goalKind, + compactionBaseOptionsForRoutedTurn + ), + muxMetadata: muxMetadataForMessage, // Frontend metadata; requestedModel re-stamped when routing applied ...(acpPromptId != null ? { acpPromptId } : {}), ...(goalKind != null ? { kind: goalKind } : {}), // Auto-resume and other system-generated messages are synthetic + UI-visible @@ -2972,8 +3178,18 @@ export class AgentSession { // before dispatching a risky user turn near the context limit. // `shouldForceCompact` remains a stricter (threshold + buffer) signal for // mid-stream forcing where we want to avoid abrupt interruptions too early. + // + // Skill-routed sends compact only when the content genuinely risks + // overrunning the routed model's window: applying the threshold to the + // (smaller) routed window would let a one-off cheap-skill invocation + // force an unrequested, irreversible, workspace-wide compaction of a + // session far under its own model's limit. The headroom accounts for + // the pending turn (new message, attachments, skill snapshot), which + // the recorded usage doesn't include yet. const shouldCompactBeforeSend = - compactionResult.usagePercentage >= compactionResult.thresholdPercentage; + skillModelOverride != null + ? compactionResult.usagePercentage >= 100 - ROUTED_SEND_COMPACTION_HEADROOM_PERCENT + : compactionResult.usagePercentage >= compactionResult.thresholdPercentage; if (shouldCompactBeforeSend) { const followUpFileParts = effectiveFileParts?.map((part) => ({ url: part.url, @@ -2999,10 +3215,15 @@ export class AgentSession { } } + // Pre-routing options/model: the deferred follow-up re-enters + // sendMessage with the same skill metadata and re-resolves routing at + // dispatch time. Persisting the routed model here would pin a stale + // decision — if the binding is gone by dispatch, the user's prompt + // would stream on the routed model with no routing decision behind it. const followUpContent = this.buildAutoCompactionFollowUp({ messageText: message, - options: optionsForStream, - modelForStream, + options: preRoutingOptions, + modelForStream: preRoutingOptions.model, fileParts: followUpFileParts, agentInitiated, goalKind, @@ -3012,7 +3233,12 @@ export class AgentSession { const autoCompactionRequest = this.buildAutoCompactionRequest({ followUpContent, - baseOptions: optionsForStream, + // The compaction request must run on the model able to read the full + // history — usually the user's pre-routing model, or the routed model + // when the class routes UP to a larger window. The deferred follow-up + // re-enters sendMessage with the same skill metadata and re-routes + // itself either way. + baseOptions: compactionBaseOptionsForRoutedTurn ?? preRoutingOptions, reason: "on-send", }); @@ -3206,7 +3432,12 @@ export class AgentSession { // Same-session retry should resume the exact accepted request we just finalized // in history, even if runtime warmup fails before streamWithHistory() starts. - this.setAutoRetryResumeState(optionsForStream, agentInitiated, goalKind); + this.setAutoRetryResumeState( + optionsForStream, + agentInitiated, + goalKind, + compactionBaseOptionsForRoutedTurn + ); try { await internal?.onAccepted?.(); } catch (error) { @@ -3234,7 +3465,9 @@ export class AgentSession { this.preparingWorkspaceTurnMetadata = getWorkspaceTurnMuxMetadata(optionsForStream.muxMetadata); this.setTurnPhase(TurnPhase.PREPARING); - const startPreparedStream = async (): Promise> => { + const startPreparedStream = async (): Promise< + Result + > => { try { if (preparedTurnAbortController.signal.aborted) { await notifyAcceptedPreStreamFailure( @@ -3273,7 +3506,8 @@ export class AgentSession { agentInitiated, preparedTurnAbortController.signal, goalKind, - turnThinkingOverride + turnThinkingOverride, + compactionBaseOptionsForRoutedTurn ); if (streamResult.success && preparedTurnAbortController.signal.aborted) { await notifyAcceptedPreStreamFailure( @@ -3282,7 +3516,7 @@ export class AgentSession { ) ); } - return streamResult; + return streamResult.success ? Ok(sendAccepted) : streamResult; } finally { // Success should advance via stream events; if startup never emitted any, don't leave the // session stuck in PREPARING. Guard by controller identity so an aborted startup cannot @@ -3332,7 +3566,7 @@ export class AgentSession { } drainQueuedMessagesAfterFailedStartup(); }); - return Ok(undefined); + return Ok(sendAccepted); } // Non-edit sends preserve the old behavior so pre-stream startup failures still propagate to @@ -3342,7 +3576,12 @@ export class AgentSession { async resumeStream( options: SendMessageOptions, - internal?: { agentInitiated?: boolean; goalKind?: GoalSyntheticMessageKind } + internal?: { + agentInitiated?: boolean; + goalKind?: GoalSyntheticMessageKind; + /** Routed-turn compaction context carried across same-session retries. */ + compactionBaseOptions?: SendMessageOptions; + } ): Promise> { this.assertNotDisposed("resumeStream"); @@ -3372,7 +3611,12 @@ export class AgentSession { // A resumed attempt becomes the latest live resume request as soon as we // accept its options, even if startup fails before the stream fully begins. - this.setAutoRetryResumeState(optionsForStream, internal?.agentInitiated, internal?.goalKind); + this.setAutoRetryResumeState( + optionsForStream, + internal?.agentInitiated, + internal?.goalKind, + internal?.compactionBaseOptions + ); this.preparingWorkspaceTurnMetadata = getWorkspaceTurnMuxMetadata(optionsForStream.muxMetadata); this.setTurnPhase(TurnPhase.PREPARING); // Open the mid-turn thinking override window for the resumed turn (after @@ -3390,7 +3634,8 @@ export class AgentSession { internal?.agentInitiated, undefined, internal?.goalKind, - turnThinkingOverride + turnThinkingOverride, + internal?.compactionBaseOptions ); if (!result.success) { return result; @@ -3434,6 +3679,52 @@ export class AgentSession { return this.lastUsageState; } + /** + * Per-model thinking floor: the configured minThinkingLevelByModel override + * resolved against the model's policy. Tests may provide partial config + * mocks, so read overrides only when available. providersConfig lets mapped + * aliases (mappedToModel) resolve against the target model's policy. + */ + private resolveThinkingFloorForModel( + modelString: string, + providersConfig: ProvidersConfigMap | null + ): ThinkingLevel { + const maybeConfig = this.config as Config & { + loadConfigOrDefault?: () => { + minThinkingLevelByModel?: Record; + } | null; + }; + // Gateway-preserving key first (an explicit coder:/ + // floor stays distinct from a direct model with the same ID), with a + // legacy name-canonical fallback for floors persisted by older versions. + const minThinkingOverride = + typeof maybeConfig.loadConfigOrDefault === "function" + ? lookupMinThinkingLevelOverride( + maybeConfig.loadConfigOrDefault()?.minThinkingLevelByModel, + modelString + ) + : undefined; + return resolveMinimumThinkingLevel(modelString, minThinkingOverride, providersConfig); + } + + /** + * Apply per-model thinking floors + policy clamping — the single definition + * used by streamWithHistory's request build AND the accepted-send payload, + * so telemetry can never report a level the stream doesn't run at. + */ + private enforceThinkingFloorsForModel( + modelString: string, + thinkingLevel: ThinkingLevel, + providersConfig: ProvidersConfigMap | null + ): ThinkingLevel { + return enforceThinkingPolicy( + modelString, + thinkingLevel, + this.resolveThinkingFloorForModel(modelString, providersConfig), + providersConfig + ); + } + private getProvidersConfigSafe(): ProvidersConfigMap | null { try { // Prefer ProviderService's safe config view: it includes env/file API-key source @@ -3839,7 +4130,10 @@ export class AgentSession { }); const autoCompactionRequest = this.buildAutoCompactionRequest({ followUpContent, - baseOptions: streamContext.options, + // Pre-routing options when the stream was skill-routed: the compaction + // request must never inherit a routed small model (it has to read the + // full uncompacted history) — mirrors the on-send compaction site. + baseOptions: streamContext.compactionBaseOptions ?? streamContext.options, reason: "mid-stream", }); @@ -3948,7 +4242,11 @@ export class AgentSession { // Session-owned per-turn holder for mid-turn thinking changes. Passed // explicitly (not read from the field) so a preempted turn can never pick // up its replacement's holder. Absent for internal retry paths. - activeTurnThinkingOverride?: ActiveTurnThinkingOverride + activeTurnThinkingOverride?: ActiveTurnThinkingOverride, + // Pre-skill-routing options for compaction requests spawned off this + // stream (see activeStreamContext.compactionBaseOptions). Passed + // explicitly like the thinking holder so retry paths stay unaffected. + compactionBaseOptions?: SendMessageOptions ): Promise> { const isStartupAbortRequested = (): boolean => abortSignal?.aborted === true; @@ -3972,6 +4270,7 @@ export class AgentSession { openaiTruncationModeOverride, ...(goalKind != null ? { goalKind } : {}), providersConfig, + ...(compactionBaseOptions != null ? { compactionBaseOptions } : {}), }; this.activeStreamUserMessageId = undefined; @@ -4056,32 +4355,9 @@ export class AgentSession { this.activeStreamHadPostCompactionInjection = postCompactionAttachments !== null && postCompactionAttachments.length > 0; - // Apply per-model thinking floors once so desktop, mobile, and ACP requests match. - // Tests may provide partial config mocks, so read overrides only when available. - const maybeConfig = this.config as Config & { - loadConfigOrDefault?: () => { - minThinkingLevelByModel?: Record; - } | null; - }; - // Gateway-preserving key first (an explicit coder:/ - // floor stays distinct from a direct model with the same ID), with a - // legacy name-canonical fallback for floors persisted by older versions. - const minThinkingOverride = - typeof maybeConfig.loadConfigOrDefault === "function" - ? lookupMinThinkingLevelOverride( - maybeConfig.loadConfigOrDefault()?.minThinkingLevelByModel, - modelString - ) - : undefined; - // Pass providersConfig so mapped aliases (mappedToModel -> e.g. GPT-5.6) - // clamp against the target model's policy — otherwise a capability level - // like native max would be stripped here before buildProviderOptions can - // resolve the alias. - const minThinkingLevel = resolveMinimumThinkingLevel( - modelString, - minThinkingOverride, - providersConfig - ); + // Mid-turn thinking overrides clamp against the same floor as the + // send-time level (single source of truth for the floor). + const minThinkingLevel = this.resolveThinkingFloorForModel(modelString, providersConfig); const effectiveThinkingLevel = options?.thinkingLevel ? enforceThinkingPolicy(modelString, options.thinkingLevel, minThinkingLevel, providersConfig) : undefined; @@ -4499,7 +4775,10 @@ export class AgentSession { true, context.agentInitiated, undefined, - context.goalKind + context.goalKind, + undefined, + // A routed turn's retry keeps its routed compaction policy. + context.compactionBaseOptions ); } finally { if (this.turnPhase === TurnPhase.PREPARING) { @@ -4822,6 +5101,13 @@ export class AgentSession { streamContext?.providersConfig ?? null ), providersConfig: streamContext?.providersConfig ?? null, + // A routed turn (compactionBaseOptions set) uses the routed-send + // policy mid-stream too: the ordinary threshold+buffer against the + // (usually smaller) routed window would immediately force the exact + // workspace-wide compaction the pre-send band declined to run. + ...(streamContext?.compactionBaseOptions != null + ? { forceThresholdPercentOverride: 100 - ROUTED_SEND_COMPACTION_HEADROOM_PERCENT } + : {}), }); if (shouldInterruptForCompaction) { @@ -5968,6 +6254,11 @@ export class AgentSession { allowAgentSetGoal: followUp.allowAgentSetGoal, disableWorkspaceAgents: followUp.disableWorkspaceAgents, skipAiSettingsPersistence: followUp.skipAiSettingsPersistence, + // An explicit one-shot carried through compaction keeps bypassing class routing. + skipSkillModelRouting: followUp.skipSkillModelRouting, + // A raw numeric thinking index re-resolves against the routed model if + // this re-dispatched send gets class-routed. + oneShotThinkingIndex: followUp.oneShotThinkingIndex, }; if (effectiveFileParts && effectiveFileParts.length > 0) { @@ -6308,6 +6599,238 @@ export class AgentSession { return { snapshotMessage, materializedTokens: tokens }; } + /** + * Build a reader that resolves a skill package with the same roots and + * precedence as skill discovery for this workspace. Shared by snapshot + * materialization and per-skill model routing so both resolve identically. + */ + private buildSkillReader(args: { + metadata: WorkspaceMetadata; + runtime: Runtime; + workspacePath: string; + disableWorkspaceAgents: boolean | undefined; + }): (skillName: string) => Promise>> { + // When workspace agents are disabled, resolve skills from the project path instead of + // the worktree so skill invocation uses the same precedence/discovery root as the UI. + const skillDiscoveryPath = args.disableWorkspaceAgents + ? args.metadata.projectPath + : args.workspacePath; + + // claude-skills-compat experiment: resolve slash-invoked skills with the same + // roots as discovery. Guard for test mocks that may not implement the gate. + const includeClaudeSkills = + typeof this.aiService.isClaudeSkillsCompatEnabled === "function" && + this.aiService.isClaudeSkillsCompatEnabled(); + // agent-plugins experiment: same treatment for plugin-provided skills. + const includeAgentPlugins = + typeof this.aiService.isAgentPluginsEnabled === "function" && + this.aiService.isAgentPluginsEnabled(); + // agent-plugins experiment: resolve host-local project workspaces through + // the same storage context as the skill read tool so checkout-level + // plugin containers stay reachable — for subProjectPath workspaces the + // execution path is a subdirectory of the checkout and default + // discovery misses them. disableWorkspaceAgents keeps default + // discovery: it anchors at projectPath, which is already the + // checkout-level root the UI lists in that mode. + const muxScope = + !args.disableWorkspaceAgents && + typeof this.aiService.resolveMuxToolScopeForWorkspace === "function" + ? this.aiService.resolveMuxToolScopeForWorkspace( + args.metadata, + args.runtime, + args.workspacePath + ) + : null; + const skillCtx = + muxScope?.type === "project" && muxScope.projectStorageAuthority === "host-local" + ? resolveSkillStorageContext({ + runtime: args.runtime, + workspacePath: skillDiscoveryPath, + muxScope, + includeClaudeSkills, + includeAgentPlugins, + }) + : null; + return (skillName: string) => + readAgentSkill( + skillCtx?.runtime ?? args.runtime, + skillCtx?.workspacePath ?? skillDiscoveryPath, + skillName, + { + ...(skillCtx != null ? { roots: skillCtx.roots, containment: skillCtx.containment } : {}), + includeClaudeSkills, + includeAgentPlugins, + } + ); + } + + /** + * Per-skill model routing: a slash-invoked skill bound to a model class + * (config `skillModelClasses` table, else skill frontmatter metadata + * "model-class") streams on the class's model for this send only. + * + * Explicit overrides win: sends carrying skipAiSettingsPersistence (one-shot + * /model commands, compaction requests) are never re-routed. Workspace AI + * settings are untouched: persistence happens in WorkspaceService (with the + * user's model) before this runs. + * + * Error posture: a *bound* skill whose routing cannot be delivered — unknown + * class, malformed class value, or a class model no configured route can + * serve — returns a config-error so the send fails with an actionable + * message instead of silently streaming on an unintended (often expensive) + * model. Unbound skills route nothing, and infrastructure failures (config + * or skill unreadable, providers state unavailable) still fail open: those + * are not user mapping mistakes, and a skill send must survive them. + */ + private async resolveSkillModelClassOverride( + muxMetadata: MuxMessageMetadata | undefined, + options: SendMessageOptions + ): Promise< + | { kind: "override"; className: string; model: string; thinkingLevel?: ThinkingLevel } + | { kind: "config-error"; message: string } + | null + > { + // Only an explicit model override suppresses routing. This must NOT key + // off skipAiSettingsPersistence: thinking-only one-shots (/+2 /skill) and + // several internal senders set that flag purely to protect persisted + // preferences and still want class routing to apply. + if (options.skipSkillModelRouting === true) { + return null; + } + if (muxMetadata?.type !== "agent-skill") { + return null; + } + + try { + // Defensive config access mirroring getPreferredCompactionSettings: test + // harnesses may provide a partial Config. + const maybeConfig = this.config as Config & { + loadConfigOrDefault?: () => { + modelClasses?: Record; + skillModelClasses?: Record; + routePriority?: string[]; + routeOverrides?: Record; + } | null; + }; + if (typeof maybeConfig.loadConfigOrDefault !== "function") { + return null; + } + const cfg = maybeConfig.loadConfigOrDefault(); + const modelClasses = cfg?.modelClasses; + const skillModelClasses = cfg?.skillModelClasses; + + const skillName = muxMetadata.skillName; + if (!SkillNameSchema.safeParse(skillName).success) { + return null; + } + + // Fast path: with no classes configured and no table binding for this + // skill, routing can never apply — skip the (possibly remote) SKILL.md + // frontmatter read entirely. The non-empty-after-trim requirement must + // match resolveSkillModelClassBinding's boundViaTable exactly: a blank + // hand-edited table entry ({done: ""}) must not suppress the frontmatter + // read and then fail the table lookup, silently unrouting the skill. + const hasModelClasses = modelClasses != null && Object.keys(modelClasses).length > 0; + const tableClassRaw = skillModelClasses?.[skillName]; + const hasTableBinding = typeof tableClassRaw === "string" && tableClassRaw.trim().length > 0; + if (!hasModelClasses && !hasTableBinding) { + return null; + } + + // Read frontmatter only when the config table doesn't already bind the + // skill — skips a (possibly remote) SKILL.md read when unnecessary. + let frontmatterMetadata: Record | undefined; + if (!hasTableBinding) { + if (typeof this.aiService.getWorkspaceMetadata !== "function") { + return null; + } + const metadataResult = await this.aiService.getWorkspaceMetadata(this.workspaceId); + if (!metadataResult.success) { + return null; + } + const { runtime, workspacePath } = createRuntimeContextForWorkspace(metadataResult.data); + const resolved = await this.buildSkillReader({ + metadata: metadataResult.data, + runtime, + workspacePath, + disableWorkspaceAgents: options.disableWorkspaceAgents, + })(skillName); + frontmatterMetadata = resolved.package.frontmatter.metadata; + } + + const providersConfig = this.getProvidersConfigSafe(); + const binding = resolveSkillModelClassBinding({ + skillName, + frontmatterMetadata, + modelClasses, + skillModelClasses, + providersConfig, + }); + + switch (binding.status) { + case "unbound": + return null; + case "unknown-class": + return { + kind: "config-error", + message: describeSkillModelClassRoutingProblem({ + kind: "unknown-class", + skillName, + className: binding.className, + }), + }; + case "invalid-value": + return { + kind: "config-error", + message: describeSkillModelClassRoutingProblem({ + kind: "invalid-value", + skillName, + className: binding.className, + value: binding.value, + }), + }; + case "resolved": { + // Availability is a routing-state question (gateways count: a model + // can be servable via OpenRouter without a direct provider key). + // Null providersConfig means "cannot determine", never "unavailable". + if ( + providersConfig != null && + !isModelServableWithProvidersConfig({ + canonicalModel: binding.model, + routePriority: cfg?.routePriority, + routeOverrides: cfg?.routeOverrides, + providersConfig, + }) + ) { + return { + kind: "config-error", + message: describeSkillModelClassRoutingProblem({ + kind: "model-unavailable", + skillName, + className: binding.className, + model: binding.model, + }), + }; + } + + log.debug( + `skill model routing: /${skillName} → class "${binding.className}" → ${binding.model}` + + (binding.thinkingLevel != null ? `+${binding.thinkingLevel}` : "") + ); + return { + kind: "override", + className: binding.className, + model: binding.model, + ...(binding.thinkingLevel != null ? { thinkingLevel: binding.thinkingLevel } : {}), + }; + } + } + } catch (error) { + log.debug(`skill model routing: fail-open for skill send: ${getErrorMessage(error)}`); + return null; + } + } + private async materializeAgentSkillSnapshots( muxMetadata: MuxMessageMetadata | undefined, disableWorkspaceAgents: boolean | undefined @@ -6334,10 +6857,6 @@ export class AgentSession { const metadata = metadataResult.data; const { runtime, workspacePath } = createRuntimeContextForWorkspace(metadata); - // When workspace agents are disabled, resolve skills from the project path instead of - // the worktree so skill invocation uses the same precedence/discovery root as the UI. - const skillDiscoveryPath = disableWorkspaceAgents ? metadata.projectPath : workspacePath; - // Dedupe per skill against recent persisted snapshots. A wider window keeps multi-skill // turns from reloading snapshots that were persisted together on the previous turn. const recentSnapshots: Array<{ skillName: string; sha256: string }> = []; @@ -6366,49 +6885,12 @@ export class AgentSession { let resolved: Awaited>; try { - // claude-skills-compat experiment: resolve slash-invoked skills with the same - // roots as discovery. Guard for test mocks that may not implement the gate. - const includeClaudeSkills = - typeof this.aiService.isClaudeSkillsCompatEnabled === "function" && - this.aiService.isClaudeSkillsCompatEnabled(); - // agent-plugins experiment: same treatment for plugin-provided skills. - const includeAgentPlugins = - typeof this.aiService.isAgentPluginsEnabled === "function" && - this.aiService.isAgentPluginsEnabled(); - // agent-plugins experiment: resolve host-local project workspaces through - // the same storage context as the skill read tool so checkout-level - // plugin containers stay reachable — for subProjectPath workspaces the - // execution path is a subdirectory of the checkout and default - // discovery misses them. disableWorkspaceAgents keeps default - // discovery: it anchors at projectPath, which is already the - // checkout-level root the UI lists in that mode. - const muxScope = - !disableWorkspaceAgents && - typeof this.aiService.resolveMuxToolScopeForWorkspace === "function" - ? this.aiService.resolveMuxToolScopeForWorkspace(metadata, runtime, workspacePath) - : null; - const skillCtx = - muxScope?.type === "project" && muxScope.projectStorageAuthority === "host-local" - ? resolveSkillStorageContext({ - runtime, - workspacePath: skillDiscoveryPath, - muxScope, - includeClaudeSkills, - includeAgentPlugins, - }) - : null; - resolved = await readAgentSkill( - skillCtx?.runtime ?? runtime, - skillCtx?.workspacePath ?? skillDiscoveryPath, - parsedName.data, - { - ...(skillCtx != null - ? { roots: skillCtx.roots, containment: skillCtx.containment } - : {}), - includeClaudeSkills, - includeAgentPlugins, - } - ); + resolved = await this.buildSkillReader({ + metadata, + runtime, + workspacePath, + disableWorkspaceAgents, + })(parsedName.data); } catch (error) { if (ref.source === "slash") { throw error; diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 119658ede5..76d7b76aba 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -1503,6 +1503,45 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "Use the `argument-hint` frontmatter field to document the expected arguments in invocation UIs.", "", + "## Per-skill model routing", + "", + "Mechanical skills (session wrap-up, worktree helpers, PR chores) rarely need your frontier model. Skill invocations can be routed to a **model class** — an indirection that survives model churn, since bindings name a class and only the class map names concrete models.", + "", + "Configure the three canonical classes — `large`, `medium`, `small` — in **Settings → Models → Model Classes** (a model plus an optional thinking level per class). Canonical names keep skill bindings portable across machines. The classes are stored in `~/.mux/config.json`, where values use the [one-shot override syntax](/config/models#one-shot-overrides): a model alias or full `provider:model` id, with an optional `+thinking` suffix (named level or model-relative numeric index). Hand-edited custom class names in config.json also work and are preserved by the Settings editor:", + "", + "```json", + "{", + ' "modelClasses": {', + ' "large": "fable+max",', + ' "medium": "sonnet+high",', + ' "small": "haiku+0"', + " }", + "}", + "```", + "", + "Bind skills to classes in either of two places:", + "", + "- **Skill frontmatter** — the spec-standard `metadata` map, so the binding travels with the skill and other agent tools ignore it:", + "", + " ```yaml", + " metadata:", + " model-class: small", + " ```", + "", + "- **Config routing table** — for skills you don't own, `skillModelClasses` in `~/.mux/config.json` maps skill names to classes and **wins over frontmatter**:", + "", + " ```json", + " {", + ' "skillModelClasses": { "done": "small", "wt": "small" }', + " }", + " ```", + "", + "Routing applies to the slash invocation's send only: the workspace's selected model is untouched, and your next message streams on it again. If auto-compaction triggers, the threshold is computed against the routed model's context window, while the compaction request itself keeps your model (it must fit the uncompacted history).", + "", + 'Broken bindings fail loudly: when a bound class exists but its value is malformed, or no configured provider route can serve its model (a retired model, a removed provider or key), the send fails with an error naming the mapping to fix — and the Model Classes editor shows the same "no configured route" warning inline. A dangling `skillModelClasses` table entry (naming a class you deleted) also errors, since the table is your own explicit routing intent. Frontmatter bindings to a class you never defined are simply ignored, so skills you don\'t own can ship `model-class` metadata without ever breaking your sends; infrastructure hiccups (an unreadable skill or config) likewise fall back to the workspace model instead of failing the send.', + "", + "To override routing for one invocation, compose a one-shot prefix with the skill: `/sonnet+high /done` runs the skill on Sonnet regardless of its class. A model-carrying one-shot always wins over class routing; a thinking-only one-shot (`/+2 /done`) layers on top of it — the skill still routes to its class model, at the overridden thinking level. Numeric thinking indices are model-relative and resolve against the model that actually streams: in `/+0 /done`, the `0` means the class model's lowest allowed level, not the workspace model's. Both overrides survive compact-and-retry: the rebuilt send keeps the one-shot's model and thinking instead of falling back to routing or ambient settings.", + "", "## Dynamic context injection (experiment)", "", "Enable the **Skill dynamic context injection** experiment (Settings → Experiments) to let skills pull live command output into their instructions. When you invoke a skill, any line whose entire content is `` !`command` `` runs in the workspace, and the line is replaced with a fenced block containing the command’s output before the model sees the skill:", @@ -3247,12 +3286,15 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "### Syntax", "", - "| Command | Effect |", - "| --------------------------- | ---------------------------------------- |", - "| `/sonnet explain this code` | Use Sonnet for one message |", - "| `/opus+high deep review` | Use Opus with high thinking |", - "| `/haiku+0 quick answer` | Use Haiku at its lowest thinking level |", - "| `/+2 analyze this` | Keep current model, set thinking level 2 |", + "| Command | Effect |", + "| --------------------------- | ------------------------------------------- |", + "| `/sonnet explain this code` | Use Sonnet for one message |", + "| `/opus+high deep review` | Use Opus with high thinking |", + "| `/haiku+0 quick answer` | Use Haiku at its lowest thinking level |", + "| `/+2 analyze this` | Keep current model, set thinking level 2 |", + "| `/haiku+0 /done` | Run the `done` skill on Haiku for this send |", + "", + "One-shot prefixes compose with [skill invocations](/agents/agent-skills): `/haiku+0 /done cleanup` invokes the skill normally (arguments, snapshots) while overriding the model for that send. An explicit one-shot also wins over the skill's own [model-class routing](/agents/agent-skills#per-skill-model-routing).", "", "### Thinking levels", "", diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index af1eccb469..383946a77a 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -501,7 +501,9 @@ interface WorkflowResultContinuationSender { requireIdle?: boolean; startStreamInBackground?: boolean; } - ): Promise>; + // The continuation sender ignores the accepted-send payload; unknown keeps + // this structural type compatible with WorkspaceService.sendMessage. + ): Promise>; } export class AIService extends EventEmitter { diff --git a/src/node/services/compactionMonitor.test.ts b/src/node/services/compactionMonitor.test.ts index 0f84fb6a33..0e7c520a1e 100644 --- a/src/node/services/compactionMonitor.test.ts +++ b/src/node/services/compactionMonitor.test.ts @@ -111,6 +111,40 @@ describe("CompactionMonitor", () => { expect(statusEvents).toHaveLength(1); }); + test("checkMidStream honors the routed-send force threshold override", () => { + const { monitor, statusEvents } = createMonitor(); + + // 75% would force-compact under the workspace threshold+buffer, but a + // routed turn's override defers until the routed window is nearly full. + expect( + monitor.checkMidStream({ + model: BETA_SONNET_MODEL, + usage: createMidStreamUsage(150_000), + use1MContext: false, + providersConfig: null, + forceThresholdPercentOverride: 90, + }) + ).toBe(false); + expect(statusEvents).toHaveLength(0); + + expect( + monitor.checkMidStream({ + model: BETA_SONNET_MODEL, + usage: createMidStreamUsage(184_000), + use1MContext: false, + providersConfig: null, + forceThresholdPercentOverride: 90, + }) + ).toBe(true); + expect(statusEvents).toEqual([ + { + type: "auto-compaction-triggered", + reason: "mid-stream", + usagePercent: 92, + }, + ]); + }); + test("checkMidStream stays disabled when threshold is set to 1.0", () => { const { monitor, statusEvents } = createMonitor(); monitor.setThreshold(1); diff --git a/src/node/services/compactionMonitor.ts b/src/node/services/compactionMonitor.ts index b4f6c10b77..5773cee76b 100644 --- a/src/node/services/compactionMonitor.ts +++ b/src/node/services/compactionMonitor.ts @@ -35,6 +35,13 @@ interface CheckMidStreamParams { usage: LanguageModelV2Usage; use1MContext: boolean; providersConfig: ProvidersConfigMap | null; + /** + * Replaces the workspace threshold+buffer force bar for this check. Skill + * class routing passes the routed-send policy (compact only near the routed + * window's limit) so a mid-stream usage update can't force the workspace-wide + * compaction the routed pre-send band deliberately avoided. + */ + forceThresholdPercentOverride?: number; } /** @@ -119,7 +126,9 @@ export class CompactionMonitor { ); const usagePercent = (usageTokens / contextLimit) * 100; - const forceThresholdPercent = this.threshold * 100 + FORCE_COMPACTION_BUFFER_PERCENT; + const forceThresholdPercent = + params.forceThresholdPercentOverride ?? + this.threshold * 100 + FORCE_COMPACTION_BUFFER_PERCENT; if (usagePercent < forceThresholdPercent) { return false; diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index 1e595aed02..219e6a3211 100644 --- a/src/node/services/providerModelFactory.ts +++ b/src/node/services/providerModelFactory.ts @@ -975,7 +975,8 @@ export class ProviderModelFactory { private isProviderAvailableForRouting( provider: ProviderName, providersConfig: ProvidersConfig, - config: ReturnType + config: ReturnType, + canonicalModel?: string ): boolean { const rawProviderConfig = providersConfig[provider] ?? {}; const providerConfig = @@ -994,6 +995,21 @@ export class ProviderModelFactory { return false; } + // Model-aware OpenAI gate, mirroring createModel's credential outcome: a + // Codex-OAuth-only credential serves only the OAuth-allowed model set, so + // direct OpenAI must not win the route for a model it would then reject + // with api_key_not_found — a usable gateway later in routePriority (or the + // caller's availability error) should win instead. Matches the shared + // canDirectOpenAIServeModel predicate used by availability preflights. + if ( + provider === "openai" && + canonicalModel != null && + !credentials.isConfigured && + !isCodexOauthAllowedModel(canonicalModel, providersConfig) + ) { + return false; + } + // Route resolution must honor the shared provider-level enabled=false switch // before considering legacy gateway-specific config gates. if (isProviderDisabledInConfig(providerConfig as { enabled?: unknown })) { @@ -2561,7 +2577,8 @@ export class ProviderModelFactory { return this.isProviderAvailableForRouting( provider as ProviderName, providersConfig, - config + config, + canonicalModel ); }, isGatewayModelAccessible @@ -2667,7 +2684,10 @@ export class ProviderModelFactory { return this.isProviderAvailableForRouting( provider as ProviderName, providersConfig, - config + config, + typeof modelKeyOrRouteContext === "string" + ? normalizeToCanonical(modelKeyOrRouteContext) + : canonicalModelString ); }, isGatewayModelAccessible diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 967778019b..2356839779 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -116,7 +116,7 @@ import type { } from "@/common/orpc/types"; import type { z } from "zod"; -import type { SendMessageError, StreamErrorType } from "@/common/types/errors"; +import type { SendMessageAccepted, SendMessageError, StreamErrorType } from "@/common/types/errors"; // Aliased to avoid clashing with the private `formatSendMessageError` string formatter below. import { formatSendMessageError as classifySendMessageError } from "@/node/services/utils/sendMessageError"; import type { IdleCompactionOutcome } from "@/node/services/idleCompactionService"; @@ -8498,7 +8498,7 @@ export class WorkspaceService extends EventEmitter { */ yieldToQueuedMessages?: boolean; } - ): Promise> { + ): Promise> { log.debug("sendMessage handler: Received", { workspaceId, messagePreview: message.substring(0, 50),