Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6781287
feat: route skills to model classes (modelClasses + skillModelClasses…
asm Aug 14, 2026
67cf862
πŸ€– fix: address review findings in skill model-class routing
asm Aug 14, 2026
6eceb41
πŸ€– fix: resolve one-shot thinking against the routed model, carry over…
asm Aug 14, 2026
012797c
πŸ€– fix: reserve headroom for the pending turn in routed-send compaction
asm Aug 14, 2026
72f9780
πŸ€– fix: apply routed compaction policy mid-stream, pick a compaction m…
asm Aug 14, 2026
6cf34ed
πŸ€– fix: gate direct OpenAI routes by usable credentials
asm Aug 14, 2026
a410448
πŸ€– fix: model-aware factory routing, defer PDF preflight, report route…
asm Aug 14, 2026
9af2b78
πŸ€– fix: drop manual useCallback in useModelClasses, pin phone snapshot…
asm Aug 14, 2026
bd22ef9
πŸ€– fix: persist class edits before publishing, report routed thinking …
asm Aug 14, 2026
74809a5
πŸ€– fix: preserve queued PDF rejections, report post-policy routed thin…
asm Aug 14, 2026
3ec7aff
πŸ€– fix: report the effective thinking level for every routed send
asm Aug 14, 2026
6cf9e7f
πŸ€– fix: carry routed compaction context across stream retries
asm Aug 14, 2026
72c988a
πŸ€– fix: preserve the composed command prefix through compact-and-retry
asm Aug 14, 2026
16ac275
πŸ€– fix: exempt shadowing custom OpenAI providers from OAuth gating
asm Aug 14, 2026
3a2ab48
πŸ€– fix: edit-safe gate rejections, pending-row locking, durable routed…
asm Aug 14, 2026
60c82d0
πŸ€– fix: restore persisted compaction context on relaunch, edit-guard t…
asm Aug 14, 2026
c718252
πŸ€– fix: resume routed retries on the persisted class model in child wo…
asm Aug 14, 2026
aade5d1
πŸ€– fix: export buildFollowUpFromSource with a null-ctx default
asm Aug 17, 2026
a56242a
πŸ€– fix: reconcile routing with the xum rename and shared AppConfigStore
asm Aug 24, 2026
abb35ad
πŸ€– fix: keep the leaked useRouting test stub shape-complete
asm Aug 24, 2026
9840eb0
πŸ€– fix: address Codex round on rebased routing (stale loaded, canonica…
asm Aug 29, 2026
4e83559
πŸ€– fix: generation-guard write acks, gate chatCompletions OAuth routes…
asm Aug 29, 2026
836dd19
πŸ€– fix: swap-reset write state, format-validate routed models, route b…
asm Aug 29, 2026
e16c845
πŸ€– fix: persist skill-send AI settings on acceptance, stamp queue time…
asm Aug 29, 2026
121cb4b
πŸ€– fix: trust-gate project frontmatter routing, schema-parse durable c…
asm Aug 29, 2026
a7a2e82
πŸ€– fix: whitelist durable compaction keys, keep gateway candidate valu…
asm Aug 29, 2026
6a11d23
πŸ€– fix: resolve authoritative skill scope before applying table bindings
asm Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,14 @@ const preview: Preview = {
styles: { width: "375px", height: "667px" },
type: "mobile",
},
// Mirrors the Pixel snapshot matrix's named "phone" width (390px):
// breakpoint-pinned stories must render locally at the exact width CI
// captures, or wrap-point regressions hide between the two.
pixelPhone: {
name: "Pixel phone (390px)",
styles: { width: "390px", height: "844px" },
type: "mobile",
},
mobile2: {
name: "iPhone XR",
styles: { width: "414px", height: "896px" },
Expand Down
41 changes: 41 additions & 0 deletions docs/agents/agent-skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,47 @@ 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 `~/.xum/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
```

For project skills, class routing only applies in **trusted projects** β€” repo-controlled content must not silently reroute your conversation to a different configured provider. In an untrusted project a project skill is never routed: not by its frontmatter, and not by a `skillModelClasses` entry either, since project skills win name collisions and a repo-shipped shadow would otherwise inherit a binding you meant for your own skill. Global and built-in skills route normally.

- **Config routing table** β€” for skills you don't own, `skillModelClasses` in `~/.xum/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, and the compaction request starts from whichever of the two models β€” the workspace's or the routed one β€” has the larger context window (it must read the full uncompacted history). As with any compaction, settings configured for the Compact agent take precedence over that base.

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:
Expand Down
15 changes: 9 additions & 6 deletions docs/config/models.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,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

Expand Down
55 changes: 46 additions & 9 deletions src/browser/features/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2780,6 +2780,9 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
api,
discovery: skillDiscovery,
signal: resolutionSignal,
// 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",
});
if (!isSendScopeCurrent()) return;
parsed = resolution.parsed;
Expand Down Expand Up @@ -2930,6 +2933,9 @@ const ChatInputInner: React.FC<ChatInputProps> = (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
Expand All @@ -2952,7 +2958,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (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 /<model-alias> one-shot override) - send directly via API
const messageTextForSend =
Expand Down Expand Up @@ -2991,11 +2997,20 @@ const ChatInputInner: React.FC<ChatInputProps> = (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 promptMuxMetadata: MuxMessageMetadata | undefined = mcpPromptInvocation
Expand All @@ -3011,12 +3026,18 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
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<ChatAttachment, { kind: "provider" }> =>
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)
Expand Down Expand Up @@ -3186,7 +3207,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (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)
Comment thread
asm marked this conversation as resolved.
Expand All @@ -3203,7 +3224,17 @@ const ChatInputInner: React.FC<ChatInputProps> = (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 }
Expand Down Expand Up @@ -3242,17 +3273,23 @@ const ChatInputInner: React.FC<ChatInputProps> = (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");
}

Expand Down
139 changes: 139 additions & 0 deletions src/browser/features/ChatInput/utils.oneShotSkillComposition.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading